diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index 845b41f..88318b4 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -61,7 +61,7 @@ class WebAppProvider(AppProvider): return self.web_handler -def make_wutta_config(settings): +def make_wutta_config(settings, config_maker=None, **kwargs): """ Make a WuttaConfig object from the given settings. @@ -93,8 +93,9 @@ def make_wutta_config(settings): "section of config to the path of your " "config file. Lame, but necessary.") - # make config per usual, add to settings - wutta_config = make_config(path) + # make config, add to settings + config_maker = config_maker or make_config + wutta_config = config_maker(path, **kwargs) settings['wutta_config'] = wutta_config # configure database sessions diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index c3567b5..ceaeeb7 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -746,17 +746,39 @@ 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): + + # TODO: i keep finding problems with this, not sure + # what needs to happen. some forms will have a simple + # dict for model_instance, others will have a proper + # SQLAlchemy object. and in the latter case, it may + # not be "wutta-native" but from another DB. + + # so the problem is, how to detect whether we should + # use the model_instance as-is or if we should convert + # to a dict. some options include: + + # - check if instance has dictify() method + # i *think* this was tried and didn't work? but do not recall + + # - check if is instance of model.Base + # this is unreliable since model.Base is wutta-native + + # - check if form has a model_class + # has not been tried yet + + # - check if schema is from colanderalchemy + # this is what we are trying currently... + + if isinstance(schema, SQLAlchemySchemaNode): kwargs['appstruct'] = schema.dictify(self.model_instance) else: kwargs['appstruct'] = self.model_instance - form = deform.Form(schema, **kwargs) + # create the Deform instance # 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 = deform.Form(schema, **kwargs) form.wutta_form = self self.deform_form = form @@ -922,18 +944,13 @@ class Form: if field_type: attrs['type'] = field_type if messages: - if len(messages) == 1: - msg = messages[0] - if msg.startswith('`') and msg.endswith('`'): - attrs[':message'] = msg - else: - attrs['message'] = msg - # TODO - # else: - # # nb. must pass an array as JSON string - # attrs[':message'] = '[{}]'.format(', '.join([ - # "'{}'".format(msg.replace("'", r"\'")) - # for msg in messages])) + cls = 'is-size-7' + if field_type == 'is-danger': + cls += ' has-text-danger' + messages = [HTML.tag('p', c=[msg], class_=cls) + for msg in messages] + slot = HTML.tag('slot', name='messages', c=messages) + html = HTML.tag('div', c=[html, slot]) return HTML.tag('b-field', c=[html], **attrs) @@ -978,7 +995,16 @@ class Form: model_data = {} def assign(field): - model_data[field.oid] = make_json_safe(field.cstruct) + value = field.cstruct + + # TODO: we need a proper true/false on the Vue side, + # but deform/colander want 'true' and 'false' ..so + # for now we explicitly translate here, ugh. also + # note this does not yet allow for null values.. :( + if isinstance(field.typ, colander.Boolean): + value = True if field.typ.true_val else False + + model_data[field.oid] = make_json_safe(value) for key in self.fields: @@ -1076,7 +1102,7 @@ class Form: """ dform = self.get_deform() if field in dform: - error = dform[field].errormsg - if error: - return [error] + field = dform[field] + if field.error: + return field.error.messages() return [] diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index a3a464b..4402fde 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -258,7 +258,12 @@ class PersonRef(ObjectRef): This is a subclass of :class:`ObjectRef`. """ - model_class = Person + + @property + def model_class(self): + """ """ + model = self.app.model + return model.Person def sort_query(self, query): """ """ diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index ee58a1a..837b6f1 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -33,14 +33,17 @@ in the namespace: * :class:`deform:deform.widget.TextAreaWidget` * :class:`deform:deform.widget.PasswordWidget` * :class:`deform:deform.widget.CheckedPasswordWidget` +* :class:`deform:deform.widget.CheckboxWidget` * :class:`deform:deform.widget.SelectWidget` * :class:`deform:deform.widget.CheckboxChoiceWidget` +* :class:`deform:deform.widget.MoneyInputWidget` """ import colander from deform.widget import (Widget, TextInputWidget, TextAreaWidget, PasswordWidget, CheckedPasswordWidget, - SelectWidget, CheckboxChoiceWidget) + CheckboxWidget, SelectWidget, CheckboxChoiceWidget, + MoneyInputWidget) from webhelpers2.html import HTML from wuttaweb.db import Session @@ -220,7 +223,7 @@ class UserRefsWidget(WuttaCheckboxChoiceWidget): users = [] if cstruct: for uuid in cstruct: - user = self.session.query(model.User).get(uuid) + user = self.session.get(model.User, uuid) if user: users.append(dict([(key, getattr(user, key)) for key in columns + ['uuid']])) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index aa2e413..0f2c812 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -1047,6 +1047,12 @@ class Grid: filters = filters or {} if self.model_class: + # TODO: i tried using self.get_model_columns() here but in + # many cases that will be too aggressive. however it is + # often the case that the *grid* columns are a subset of + # the unerlying *table* columns. so until a better way + # is found, we choose "too few" instead of "too many" + # filters here. surely must improve it at some point. for key in self.columns: if key in filters: continue diff --git a/src/wuttaweb/templates/deform/checkbox.pt b/src/wuttaweb/templates/deform/checkbox.pt index 92c9f62..c4536e8 100644 --- a/src/wuttaweb/templates/deform/checkbox.pt +++ b/src/wuttaweb/templates/deform/checkbox.pt @@ -6,6 +6,6 @@ v-model="${vmodel}" native-value="true" tal:attributes="attributes|field.widget.attributes|{};"> - {{ ${vmodel} }} + {{ ${vmodel} ? "Yes" : "No" }} diff --git a/src/wuttaweb/templates/deform/moneyinput.pt b/src/wuttaweb/templates/deform/moneyinput.pt new file mode 100644 index 0000000..532a823 --- /dev/null +++ b/src/wuttaweb/templates/deform/moneyinput.pt @@ -0,0 +1,7 @@ + + + diff --git a/src/wuttaweb/templates/deform/readonly/checkbox.pt b/src/wuttaweb/templates/deform/readonly/checkbox.pt new file mode 100644 index 0000000..4988213 --- /dev/null +++ b/src/wuttaweb/templates/deform/readonly/checkbox.pt @@ -0,0 +1,4 @@ + + Yes + No + diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index edcdc24..1ed408e 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -19,7 +19,7 @@ - + `` tag is also given a ``title`` + attribute with the original (full) text, so that appears on + mouse hover. + + To use this feature for your grid:: + + grid.set_renderer('my_notes_field', self.grid_render_notes) + + # you can also override maxlen + grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50) + """ + if value is None: + return + + if len(value) < maxlen: + return value + + return HTML.tag('span', title=value, c=f"{value[:maxlen]}...") + ############################## # support methods ############################## @@ -1317,9 +1395,8 @@ class MasterView(View): Default logic for this method returns a "plain" query on the :attr:`model_class` if that is defined; otherwise ``None``. """ - model = self.app.model model_class = self.get_model_class() - if model_class and issubclass(model_class, model.Base): + if model_class: session = session or self.Session() return session.query(model_class) @@ -1344,33 +1421,6 @@ class MasterView(View): # for key in self.get_model_key(): # grid.set_link(key) - def grid_render_notes(self, record, key, value, maxlen=100): - """ - Custom grid renderer callable for "notes" fields. - - If the given text ``value`` is shorter than ``maxlen`` - characters, it is returned as-is. - - But if it is longer, then it is truncated and an ellispsis is - added. The resulting ```` tag is also given a ``title`` - attribute with the original (full) text, so that appears on - mouse hover. - - To use this feature for your grid:: - - grid.set_renderer('my_notes_field', self.grid_render_notes) - - # you can also override maxlen - grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50) - """ - if value is None: - return - - if len(value) < maxlen: - return value - - return HTML.tag('span', title=value, c=f"{value[:maxlen]}...") - def get_instance(self, session=None): """ This should return the "current" model instance based on the diff --git a/src/wuttaweb/views/people.py b/src/wuttaweb/views/people.py index a19df57..78cf931 100644 --- a/src/wuttaweb/views/people.py +++ b/src/wuttaweb/views/people.py @@ -123,6 +123,12 @@ class PersonView(MasterView): @classmethod def defaults(cls, config): """ """ + + # nb. Person may come from custom model + wutta_config = config.registry.settings['wutta_config'] + app = wutta_config.get_app() + cls.model_class = app.model.Person + cls._defaults(config) cls._people_defaults(config) diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index a20e1f6..aa28416 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -51,6 +51,7 @@ class AppInfoView(MasterView): model_name = 'AppInfo' model_title_plural = "App Info" route_prefix = 'appinfo' + filterable = False sort_on_backend = False sort_defaults = 'name' paginated = False diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py index 91ed2e0..5e28a26 100644 --- a/src/wuttaweb/views/users.py +++ b/src/wuttaweb/views/users.py @@ -207,6 +207,17 @@ class UserView(MasterView): role = session.get(model.Role, uuid) user.roles.remove(role) + @classmethod + def defaults(cls, config): + """ """ + + # nb. User may come from custom model + wutta_config = config.registry.settings['wutta_config'] + app = wutta_config.get_app() + cls.model_class = app.model.User + + cls._defaults(config) + def defaults(config, **kwargs): base = globals() diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index 70cb51f..73e9d85 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -461,15 +461,10 @@ class TestForm(TestCase): # nb. no error message self.assertNotIn('message', html) - # with single "static" error - dform['foo'].error = MagicMock(msg="something is wrong") - html = form.render_vue_field('foo') - self.assertIn(' message="something is wrong"', html) - - # with single "dynamic" error - dform['foo'].error = MagicMock(msg="`something is wrong`") - html = form.render_vue_field('foo') - self.assertIn(':message="`something is wrong`"', html) + # with error message + with patch.object(form, 'get_field_errors', return_value=['something is wrong']): + html = form.render_vue_field('foo') + self.assertIn('something is wrong', html) # add another field, but not to deform, so it should still # display but with no widget @@ -525,20 +520,33 @@ class TestForm(TestCase): data = form.get_vue_model_data() self.assertEqual(len(data), 2) + # confirm bool values make it thru as-is + schema.add(colander.SchemaNode(colander.Bool(), name='baz')) + form = self.make_form(schema=schema, model_instance={ + 'foo': 'one', + 'bar': 'two', + 'baz': True, + }) + data = form.get_vue_model_data() + self.assertEqual(list(data.values()), ['one', 'two', True]) + def test_get_field_errors(self): schema = self.make_schema() + + # simple 'Required' validation failure form = self.make_form(schema=schema) - dform = form.get_deform() + self.request.method = 'POST' + self.request.POST = {'foo': 'one'} + self.assertFalse(form.validate()) + errors = form.get_field_errors('bar') + self.assertEqual(errors, ['Required']) - # no error - errors = form.get_field_errors('foo') - self.assertEqual(len(errors), 0) - - # simple error - dform['foo'].error = MagicMock(msg="something is wrong") - errors = form.get_field_errors('foo') - self.assertEqual(len(errors), 1) - self.assertEqual(errors[0], "something is wrong") + # no errors + form = self.make_form(schema=schema) + self.request.POST = {'foo': 'one', 'bar': 'two'} + self.assertTrue(form.validate()) + errors = form.get_field_errors('bar') + self.assertEqual(errors, []) def test_validate(self): schema = self.make_schema() diff --git a/tests/views/test_master.py b/tests/views/test_master.py index d000693..023449a 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -1,5 +1,6 @@ # -*- coding: utf-8; -*- +import decimal import functools from unittest import TestCase from unittest.mock import MagicMock, patch @@ -558,6 +559,44 @@ class TestMasterView(WebTestCase): view.configure_grid(grid) self.assertNotIn('uuid', grid.columns) + def test_grid_render_bool(self): + model = self.app.model + view = self.make_view() + user = model.User(username='barney', active=None) + + # null + value = view.grid_render_bool(user, 'active', None) + self.assertIsNone(value) + + # true + user.active = True + value = view.grid_render_bool(user, 'active', True) + self.assertEqual(value, "Yes") + + # false + user.active = False + value = view.grid_render_bool(user, 'active', False) + self.assertEqual(value, "No") + + def test_grid_render_currency(self): + model = self.app.model + view = self.make_view() + obj = {'amount': None} + + # null + value = view.grid_render_currency(obj, 'amount', None) + self.assertIsNone(value) + + # normal amount + obj['amount'] = decimal.Decimal('100.42') + value = view.grid_render_currency(obj, 'amount', '100.42') + self.assertEqual(value, "$100.42") + + # negative amount + obj['amount'] = decimal.Decimal('-100.42') + value = view.grid_render_currency(obj, 'amount', '-100.42') + self.assertEqual(value, "($100.42)") + def test_grid_render_notes(self): model = self.app.model view = self.make_view()