diff --git a/docs/api/wuttaweb/forms.schema.rst b/docs/api/wuttaweb/forms.schema.rst new file mode 100644 index 0000000..333ade6 --- /dev/null +++ b/docs/api/wuttaweb/forms.schema.rst @@ -0,0 +1,6 @@ + +``wuttaweb.forms.schema`` +========================= + +.. automodule:: wuttaweb.forms.schema + :members: diff --git a/docs/api/wuttaweb/forms.widgets.rst b/docs/api/wuttaweb/forms.widgets.rst new file mode 100644 index 0000000..2fe509c --- /dev/null +++ b/docs/api/wuttaweb/forms.widgets.rst @@ -0,0 +1,6 @@ + +``wuttaweb.forms.widgets`` +========================== + +.. automodule:: wuttaweb.forms.widgets + :members: diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 93ba626..273dd66 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -12,6 +12,8 @@ db forms forms.base + forms.schema + forms.widgets grids grids.base handler @@ -28,3 +30,4 @@ views.master views.people views.settings + views.users diff --git a/docs/api/wuttaweb/views.users.rst b/docs/api/wuttaweb/views.users.rst new file mode 100644 index 0000000..f4563a9 --- /dev/null +++ b/docs/api/wuttaweb/views.users.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.users`` +======================== + +.. automodule:: wuttaweb.views.users + :members: diff --git a/pyproject.toml b/pyproject.toml index 91c1912..c52ab1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ ] requires-python = ">= 3.8" dependencies = [ + "ColanderAlchemy", "pyramid>=2", "pyramid_beaker", "pyramid_deform", diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index 1ecff26..7ee9b01 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -24,65 +24,20 @@ Base form classes """ -import json import logging import colander import deform +from colanderalchemy import SQLAlchemySchemaNode from pyramid.renderers import render from webhelpers2.html import HTML -from wuttaweb.util import get_form_data, get_model_fields +from wuttaweb.util import FieldList, get_form_data, get_model_fields, make_json_safe log = logging.getLogger(__name__) -class FieldList(list): - """ - Convenience wrapper for a form's field list. This is a subclass - of :class:`python:list`. - - You normally would not need to instantiate this yourself, but it - is used under the hood for :attr:`Form.fields` as well as - :attr:`~wuttaweb.grids.base.Grid.columns`. - """ - - def insert_before(self, field, newfield): - """ - Insert a new field, before an existing field. - - :param field: String name for the existing field. - - :param newfield: String name for the new field, to be inserted - just before the existing ``field``. - """ - if field in self: - i = self.index(field) - self.insert(i, newfield) - else: - log.warning("field '%s' not found, will append new field: %s", - field, newfield) - self.append(newfield) - - def insert_after(self, field, newfield): - """ - Insert a new field, after an existing field. - - :param field: String name for the existing field. - - :param newfield: String name for the new field, to be inserted - just after the existing ``field``. - """ - if field in self: - i = self.index(field) - self.insert(i + 1, newfield) - else: - log.warning("field '%s' not found, will append new field: %s", - field, newfield) - self.append(newfield) - - class Form: """ Base class for all forms. @@ -112,9 +67,11 @@ class Form: .. attribute:: fields - :class:`FieldList` instance containing string field names for - the form. By default, fields will appear in the same order as - they are in this list. + :class:`~wuttaweb.util.FieldList` instance containing string + field names for the form. By default, fields will appear in + the same order as they are in this list. + + See also :meth:`set_fields()`. .. attribute:: schema @@ -142,6 +99,27 @@ class Form: SQLAlchemy-mapped. (In that case :attr:`model_class` can be determined automatically.) + .. attribute:: nodes + + Dict of node overrides, used to construct the form in + :meth:`get_schema()`. + + See also :meth:`set_node()`. + + .. attribute:: widgets + + Dict of widget overrides, used to construct the form in + :meth:`get_schema()`. + + See also :meth:`set_widget()`. + + .. attribute:: validators + + Dict of node validators, used to construct the form in + :meth:`get_schema()`. + + See also :meth:`set_validator()`. + .. attribute:: readonly Boolean indicating the form does not allow submit. In practice @@ -267,6 +245,9 @@ class Form: schema=None, model_class=None, model_instance=None, + nodes={}, + widgets={}, + validators={}, readonly=False, readonly_fields=[], required_fields={}, @@ -287,6 +268,9 @@ class Form: ): self.request = request self.schema = schema + self.nodes = nodes or {} + self.widgets = widgets or {} + self.validators = validators or {} self.readonly = readonly self.readonly_fields = set(readonly_fields or []) self.required_fields = required_fields or {} @@ -311,13 +295,10 @@ class Form: self.model_class = model_class self.model_instance = model_instance if self.model_instance and not self.model_class: - self.model_class = type(self.model_instance) + if type(self.model_instance) is not dict: + self.model_class = type(self.model_instance) - fields = fields or self.get_fields() - if fields: - self.set_fields(fields) - else: - self.fields = None + self.set_fields(fields or self.get_fields()) def __contains__(self, name): """ @@ -388,12 +369,108 @@ class Form: Explicitly set the list of form fields. This will overwrite :attr:`fields` with a new - :class:`FieldList` instance. + :class:`~wuttaweb.util.FieldList` instance. :param fields: List of string field names. """ self.fields = FieldList(fields) + def remove(self, *keys): + """ + Remove some fields(s) from the form. + + This is a convenience to allow removal of multiple fields at + once:: + + form.remove('first_field', + 'second_field', + 'third_field') + + It will remove each field from :attr:`fields`. + """ + for key in keys: + if key in self.fields: + self.fields.remove(key) + + def set_node(self, key, nodeinfo, **kwargs): + """ + Set/override the node for a field. + + :param key: Name of field. + + :param nodeinfo: Should be either a + :class:`colander:colander.SchemaNode` instance, or else a + :class:`colander:colander.SchemaType` instance. + + If ``nodeinfo`` is a proper node instance, it will be used + as-is. Otherwise an + :class:`~wuttaweb.forms.schema.ObjectNode` instance will be + constructed using ``nodeinfo`` as the type (``typ``). + + Node overrides are tracked via :attr:`nodes`. + """ + if isinstance(nodeinfo, colander.SchemaNode): + # assume nodeinfo is a complete node + node = nodeinfo + + else: # assume nodeinfo is a schema type + kwargs.setdefault('name', key) + + from wuttaweb.forms.schema import ObjectNode + + # node = colander.SchemaNode(nodeinfo, **kwargs) + node = ObjectNode(nodeinfo, **kwargs) + + self.nodes[key] = node + + # must explicitly replace node, if we already have a schema + if self.schema: + self.schema[key] = node + + def set_widget(self, key, widget): + """ + Set/override the widget for a field. + + :param key: Name of field. + + :param widget: Instance of + :class:`deform:deform.widget.Widget`. + + Widget overrides are tracked via :attr:`widgets`. + """ + self.widgets[key] = widget + + # update schema if necessary + if self.schema and key in self.schema: + self.schema[key].widget = widget + + def set_validator(self, key, validator): + """ + Set/override the validator for a field, or the form. + + :param key: Name of field. This may also be ``None`` in which + case the validator will apply to the whole form instead of + a field. + + :param validator: Callable which accepts ``(node, value)`` + args. For instance:: + + def validate_foo(node, value): + if value == 42: + node.raise_invalid("42 is not allowed!") + + form = Form(fields=['foo', 'bar']) + + form.set_validator('foo', validate_foo) + + Validator overrides are tracked via :attr:`validators`. + """ + self.validators[key] = validator + + # nb. must apply to existing schema if present + if self.schema and key in self.schema: + self.schema[key].validator = validator + def set_readonly(self, key, readonly=True): """ Enable or disable the "readonly" flag for a given field. @@ -512,6 +589,8 @@ class Form: if fields: return fields + return [] + def get_model_fields(self, model_class=None): """ This method is a shortcut which calls @@ -534,25 +613,83 @@ class Form: """ if not self.schema: + ############################## + # create schema + ############################## + # get fields fields = self.get_fields() if not fields: raise NotImplementedError - # make basic schema - schema = colander.Schema() - for name in fields: - schema.add(colander.SchemaNode( - colander.String(), - name=name)) + if self.model_class: + + # first define full list of 'includes' - final schema + # should contain all of these fields + includes = list(fields) + + # determine which we want ColanderAlchemy to handle + auto_includes = [] + for key in includes: + + # skip if we already have a node defined + if key in self.nodes: + continue + + # we want the magic for this field + auto_includes.append(key) + + # make initial schema with ColanderAlchemy magic + schema = SQLAlchemySchemaNode(self.model_class, + includes=auto_includes) + + # now fill in the blanks for non-magic fields + for key in includes: + if key not in auto_includes: + node = self.nodes[key] + schema.add(node) + + else: + + # make basic schema + schema = colander.Schema() + for key in fields: + node = None + + # use node override if present + if key in self.nodes: + node = self.nodes[key] + if not node: + + # otherwise make simple string node + node = colander.SchemaNode( + colander.String(), + name=key) + + schema.add(node) + + ############################## + # customize schema + ############################## + + # apply widget overrides + for key, widget in self.widgets.items(): + if key in schema: + schema[key].widget = widget + + # apply validator overrides + for key, validator in self.validators.items(): + if key is None: + # nb. this one is form-wide + schema.validator = validator + elif key in schema: # field-level + schema[key].validator = validator # apply required flags for key, required in self.required_fields.items(): if key in schema: if required is False: - # TODO: (why) should we not use colander.null here? - #schema[key].missing = colander.null - schema[key].missing = None + schema[key].missing = colander.null self.schema = schema @@ -569,16 +706,13 @@ class Form: kwargs = {} if self.model_instance: + # TODO: would it be smarter to test with hasattr() ? + # if hasattr(schema, 'dictify'): if isinstance(self.model_instance, model.Base): - kwargs['appstruct'] = dict(self.model_instance) + kwargs['appstruct'] = schema.dictify(self.model_instance) else: kwargs['appstruct'] = self.model_instance - # TODO: ugh why is this necessary? - for key, value in list(kwargs['appstruct'].items()): - if value is None: - kwargs['appstruct'][key] = colander.null - form = deform.Form(schema, **kwargs) self.deform_form = form @@ -632,6 +766,7 @@ class Form: context['dform'] = self.get_deform() context.setdefault('form_attrs', {}) context.setdefault('request', self.request) + context['model_data'] = self.get_vue_model_data() # auto disable button on submit if self.auto_disable_submit: @@ -729,50 +864,44 @@ class Form: return HTML.tag('b-field', c=[html], **attrs) - def get_field_errors(self, field): + def get_vue_model_data(self): """ - Return a list of error messages for the given field. + Returns a dict with form model data. Values may be nested + depending on the types of fields contained in the form. - Not useful unless a call to :meth:`validate()` failed. + Note that the values need not be "converted" (to be + JSON-compatible) at this stage, for instance ``colander.null`` + is not a problem here. The point is to collect the raw data. + + The dict should have a key/value for each field in the form. + + This method is called by :meth:`render_vue_model_data()` which + is responsible for ensuring JSON compatibility. """ dform = self.get_deform() - if field in dform: - error = dform[field].errormsg - if error: - return [error] - return [] + model_data = {} - def get_vue_field_value(self, field): - """ - This method returns a JSON string which will be assigned as - the initial model value for the given field. This JSON will - be written as part of the overall response, to be interpreted - on the client side. + def assign(field): + model_data[field.oid] = make_json_safe(field.cstruct) - Again, this must return a *string* such as: + for key in self.fields: - * ``'null'`` - * ``'{"foo": "bar"}'`` + # TODO: i thought commented code was useful, but no longer sure? - In practice this calls :meth:`jsonify_value()` to convert the - ``field.cstruct`` value to string. - """ - if isinstance(field, str): - dform = self.get_deform() - field = dform[field] + # TODO: need to describe the scenario when this is true + if key not in dform: + # log.warning("field '%s' is missing from deform", key) + continue - return self.jsonify_value(field.cstruct) + field = dform[key] - def jsonify_value(self, value): - """ - Convert a Python value to JSON string. + # if hasattr(field, 'children'): + # for subfield in field.children: + # assign(subfield) - See also :meth:`get_vue_field_value()`. - """ - if value is colander.null: - return 'null' + assign(field) - return json.dumps(value) + return model_data def validate(self): """ @@ -824,6 +953,20 @@ class Form: try: self.validated = dform.validate(controls) except deform.ValidationFailure: + log.debug("form not valid: %s", dform.error) return False return self.validated + + def get_field_errors(self, field): + """ + Return a list of error messages for the given field. + + Not useful unless a call to :meth:`validate()` failed. + """ + dform = self.get_deform() + if field in dform: + error = dform[field].errormsg + if error: + return [error] + return [] diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py new file mode 100644 index 0000000..ccb357f --- /dev/null +++ b/src/wuttaweb/forms/schema.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +Form schema types +""" + +import colander + +from wuttaweb.db import Session +from wuttaweb.forms import widgets +from wuttjamaican.db.model import Person + + +class ObjectNode(colander.SchemaNode): + """ + Custom schema node class which adds methods for compatibility with + ColanderAlchemy. This is a direct subclass of + :class:`colander:colander.SchemaNode`. + + ColanderAlchemy will call certain methods on any node found in the + schema. However these methods are not "standard" and only exist + for ColanderAlchemy nodes. + + So we must add nodes using this class, to ensure the node has all + methods needed by ColanderAlchemy. + """ + + def dictify(self, obj): + """ + This method is called by ColanderAlchemy when translating the + in-app Python object to a value suitable for use in the form + data dict. + + The logic here will look for a ``dictify()`` method on the + node's "type" instance (``self.typ``; see also + :class:`colander:colander.SchemaNode`) and invoke it if found. + + For an example type which is supported in this way, see + :class:`ObjectRef`. + + If the node's type does not have a ``dictify()`` method, this + will raise ``NotImplementeError``. + """ + if hasattr(self.typ, 'dictify'): + return self.typ.dictify(obj) + + class_name = self.typ.__class__.__name__ + raise NotImplementedError(f"you must define {class_name}.dictify()") + + def objectify(self, value): + """ + This method is called by ColanderAlchemy when translating form + data to the final Python representation. + + The logic here will look for an ``objectify()`` method on the + node's "type" instance (``self.typ``; see also + :class:`colander:colander.SchemaNode`) and invoke it if found. + + For an example type which is supported in this way, see + :class:`ObjectRef`. + + If the node's type does not have an ``objectify()`` method, + this will raise ``NotImplementeError``. + """ + if hasattr(self.typ, 'objectify'): + return self.typ.objectify(value) + + class_name = self.typ.__class__.__name__ + raise NotImplementedError(f"you must define {class_name}.objectify()") + + +class ObjectRef(colander.SchemaType): + """ + Custom schema type for a model class reference field. + + This expects the incoming ``appstruct`` to be either a model + record instance, or ``None``. + + Serializes to the instance UUID as string, or ``colander.null``; + form data should be of the same nature. + + This schema type is not useful directly, but various other types + will subclass it. Each should define (at least) the + :attr:`model_class` attribute or property. + + :param request: Current :term:`request` object. + + :param empty_option: If a select widget is used, this determines + whether an empty option is included for the dropdown. Set + this to one of the following to add an empty option: + + * ``True`` to add the default empty option + * label text for the empty option + * tuple of ``(value, label)`` for the empty option + + Note that in the latter, ``value`` must be a string. + """ + + default_empty_option = ('', "(none)") + + def __init__( + self, + request, + empty_option=None, + session=None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + self.model_instance = None + self.session = session or Session() + + if empty_option: + if empty_option is True: + self.empty_option = self.default_empty_option + elif isinstance(empty_option, tuple) and len(empty_option) == 2: + self.empty_option = empty_option + else: + self.empty_option = ('', str(empty_option)) + else: + self.empty_option = None + + @property + def model_class(self): + """ + Should be a reference to the model class to which this schema + type applies + (e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`). + """ + class_name = self.__class__.__name__ + raise NotImplementedError(f"you must define {class_name}.model_class") + + def serialize(self, node, appstruct): + """ """ + if appstruct is colander.null: + return colander.null + + # nb. keep a ref to this for later use + node.model_instance = appstruct + + # serialize to uuid + return appstruct.uuid + + def deserialize(self, node, cstruct): + """ """ + if not cstruct: + return colander.null + + # nb. use shortcut to fetch model instance from DB + return self.objectify(cstruct) + + def dictify(self, obj): + """ """ + + # TODO: would we ever need to do something else? + return obj + + def objectify(self, value): + """ + For the given UUID value, returns the object it represents + (based on :attr:`model_class`). + + If the value is empty, returns ``None``. + + If the value is not empty but object cannot be found, raises + ``colander.Invalid``. + """ + if not value: + return + + if isinstance(value, self.model_class): + return value + + # fetch object from DB + model = self.app.model + obj = self.session.query(self.model_class).get(value) + + # raise error if not found + if not obj: + class_name = self.model_class.__name__ + raise ValueError(f"{class_name} not found: {value}") + + return obj + + def get_query(self): + """ + Returns the main SQLAlchemy query responsible for locating the + dropdown choices for the select widget. + + This is called by :meth:`widget_maker()`. + """ + query = self.session.query(self.model_class) + query = self.sort_query(query) + return query + + def sort_query(self, query): + """ + TODO + """ + return query + + def widget_maker(self, **kwargs): + """ + This method is responsible for producing the default widget + for the schema node. + + Deform calls this method automatically when constructing the + default widget for a field. + + :returns: Instance of + :class:`~wuttaweb.forms.widgets.ObjectRefWidget`. + """ + + if 'values' not in kwargs: + query = self.get_query() + objects = query.all() + values = [(obj.uuid, str(obj)) + for obj in objects] + if self.empty_option: + values.insert(0, self.empty_option) + kwargs['values'] = values + + return widgets.ObjectRefWidget(self.request, **kwargs) + + +class PersonRef(ObjectRef): + """ + Custom schema type for a ``Person`` reference field. + + This is a subclass of :class:`ObjectRef`. + """ + model_class = Person + + def sort_query(self, query): + """ """ + return query.order_by(self.model_class.full_name) diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py new file mode 100644 index 0000000..6627375 --- /dev/null +++ b/src/wuttaweb/forms/widgets.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +Form widgets + +This module defines some custom widgets for use with WuttaWeb. + +However for convenience it also makes other Deform widgets available +in the namespace: + +* :class:`deform:deform.widget.Widget` (base class) +* :class:`deform:deform.widget.TextInputWidget` +* :class:`deform:deform.widget.SelectWidget` +""" + +from deform.widget import Widget, TextInputWidget, SelectWidget +from webhelpers2.html import HTML + + +class ObjectRefWidget(SelectWidget): + """ + Widget for use with model "object reference" fields, e.g. foreign + key UUID => TargetModel instance. + + While you may create instances of this widget directly, it + normally happens automatically when schema nodes of the + :class:`~wuttaweb.forms.schema.ObjectRef` (sub)type are part of + the form schema; via + :meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`. + + .. attribute:: model_instance + + Reference to the model record instance, i.e. the "far side" of + the foreign key relationship. + + .. note:: + + You do not need to provide the ``model_instance`` when + constructing the widget. Rather, it is set automatically + when the :class:`~wuttaweb.forms.schema.ObjectRef` type + instance (associated with the node) is serialized. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, field, cstruct, **kw): + """ + Serialize the widget. + + In readonly mode, returns a ```` tag around the + :attr:`model_instance` rendered as string. + + Otherwise renders via the ``deform/select`` template. + """ + readonly = kw.get('readonly', self.readonly) + if readonly: + obj = field.schema.model_instance + return HTML.tag('span', c=str(obj or '')) + + return super().serialize(field, cstruct, **kw) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 0008699..a53cca4 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -24,11 +24,18 @@ Base grid classes """ +import json +import logging + +import sqlalchemy as sa + from pyramid.renderers import render from webhelpers2.html import HTML -from wuttaweb.forms import FieldList -from wuttaweb.util import get_model_fields +from wuttaweb.util import FieldList, get_model_fields, make_json_safe + + +log = logging.getLogger(__name__) class Grid: @@ -114,11 +121,7 @@ class Grid: self.config = self.request.wutta_config self.app = self.config.get_app() - columns = columns or self.get_columns() - if columns: - self.set_columns(columns) - else: - self.columns = None + self.set_columns(columns or self.get_columns()) def get_columns(self): """ @@ -139,6 +142,8 @@ class Grid: if columns: return columns + return [] + def get_model_columns(self, model_class=None): """ This method is a shortcut which calls @@ -172,6 +177,23 @@ class Grid: """ self.columns = FieldList(columns) + def remove(self, *keys): + """ + Remove some column(s) from the grid. + + This is a convenience to allow removal of multiple columns at + once:: + + grid.remove('first_field', + 'second_field', + 'third_field') + + It will remove each column from :attr:`columns`. + """ + for key in keys: + if key in self.columns: + self.columns.remove(key) + def set_link(self, key, link=True): """ Explicitly enable or disable auto-link behavior for a given @@ -296,22 +318,52 @@ class Grid: Returns a list of Vue-compatible data records. This uses :attr:`data` as the basis, but may add some extra - values to each record for sake of action URLs etc. + values to each record, e.g. URLs for :attr:`actions` etc. - See also :meth:`get_vue_columns()`. + Importantly, this also ensures each value in the dict is + JSON-serializable, using + :func:`~wuttaweb.util.make_json_safe()`. + + :returns: List of data record dicts for use with Vue table + component. """ - # use data as-is unless we have actions - if not self.actions: - return self.data + original_data = self.data or [] + + # TODO: at some point i thought it was useful to wrangle the + # columns here, but now i can't seem to figure out why..? + + # # determine which columns are relevant for data set + # columns = None + # if not columns: + # columns = self.get_columns() + # if not columns: + # raise ValueError("cannot determine columns for the grid") + # columns = set(columns) + # if self.model_class: + # mapper = sa.inspect(self.model_class) + # for column in mapper.primary_key: + # columns.add(column.key) + + # # prune data fields for which no column is defined + # for i, record in enumerate(original_data): + # original_data[i]= dict([(key, record[key]) + # for key in columns]) # we have action(s), so add URL(s) for each record in data data = [] - for i, record in enumerate(self.data): - record = dict(record) + for i, record in enumerate(original_data): + + # convert data if needed, for json compat + record = make_json_safe(record, + # TODO: is this a good idea? + warn=False) + + # add action urls to each record for action in self.actions: url = action.get_url(record, i) key = f'_action_url_{action.key}' record[key] = url + data.append(record) return data diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py index b07c0e4..0dfb6f5 100644 --- a/src/wuttaweb/menus.py +++ b/src/wuttaweb/menus.py @@ -147,6 +147,12 @@ class MenuHandler(GenericHandler): 'title': "Admin", 'type': 'menu', 'items': [ + { + 'title': "Users", + 'route': 'users', + 'perm': 'users.list', + }, + {'type': 'sep'}, { 'title': "App Info", 'route': 'appinfo', diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako index 7354514..e92a660 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -3,7 +3,7 @@ <%def name="page_content()"> -