diff --git a/CHANGELOG.md b/CHANGELOG.md index b1444a6..9b75345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +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.28.0 (2026-02-17) - -### Feat - -- add support for association proxy fields in model forms - -### Fix - -- fix first_name, last_name handling for User form -- add support for rendering association proxy values in grid -- add "centered" flag for grid columns -- prevent delete also, for users with prevent edit flag -- render enabled flag as bool for Email Settings grid - ## v0.27.6 (2026-02-13) ### Fix diff --git a/docs/conf.py b/docs/conf.py index 10dac1c..7465596 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,6 @@ exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] intersphinx_mapping = { "alembic": ("https://alembic.sqlalchemy.org/en/latest/", None), "colander": ("https://docs.pylonsproject.org/projects/colander/en/latest/", None), - "colanderalchemy": ("https://colanderalchemy.readthedocs.io/en/latest/", None), "deform": ("https://docs.pylonsproject.org/projects/deform/en/latest/", None), "fanstatic": ("https://www.fanstatic.org/en/latest/", None), "pyramid": ("https://docs.pylonsproject.org/projects/pyramid/en/latest/", None), diff --git a/pyproject.toml b/pyproject.toml index d2be8c5..bccf7ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.28.0" +version = "0.27.6" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -45,7 +45,7 @@ dependencies = [ "SQLAlchemy-Utils", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.28.7", + "WuttJamaican[db]>=0.28.1", "zope.sqlalchemy>=1.5", # nb. this must be pinned for now, until pyramid can remove diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index b1e142a..0d2a42d 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -827,7 +827,7 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth includes.append(key) # make initial schema with ColanderAlchemy magic - schema = WuttaSchemaNode(self.model_class, includes=includes) + schema = SQLAlchemySchemaNode(self.model_class, includes=includes) # fill in the blanks if anything got missed for key in fields: @@ -1144,7 +1144,7 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth # render static text if field not in deform/schema # TODO: need to abstract this somehow if self.model_instance: - value = self.app.get_value(self.model_instance, fieldname) + value = self.model_instance[fieldname] html = str(value) if value is not None else "" else: html = "" @@ -1407,160 +1407,3 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth if field.error: return field.error.messages() return [] - - -class WuttaSchemaNode(SQLAlchemySchemaNode): # pylint: disable=abstract-method - """ - Custom schema node type based on ColanderAlchemy, but adding - support for association proxy fields. - - This class is used under the hood but you will not normally - interact with it directly. - - It's a subclass of - :class:`colanderalchemy:colanderalchemy.SQLAlchemySchemaNode`. - """ - - def add_nodes(self, includes, excludes, overrides): - super().add_nodes(includes, excludes, overrides) - - for name in includes or []: - prop = self.inspector.attrs.get(name, name) - if isinstance(prop, str): - - # add node for association proxy field - if column := get_association_proxy_column(self.inspector, name): - name_overrides_copy = overrides.get(name, {}).copy() - node = self.get_schema_from_column(column, name_overrides_copy) - if node is not None: - self.add(node) - - def dictify(self, obj): # pylint: disable=empty-docstring - """ """ - dct = super().dictify(obj) - - # loop thru all fields to add the association proxies - for node in self: - name = node.name - if name in dct: - continue # value already set - - # we only care about association proxies here - if not get_association_proxy_column(self.inspector, name): - continue - - dct[name] = getattr(obj, name) - - # special handling when value is None - if dct[name] is None: - - # nb. colander/deform know how to behave when using - # their dedicated colander.null value, but ``None`` - # seems to cause issues for string fields, so swap - # that out here if applicable - if isinstance(node.typ, colander.String): - dct[name] = colander.null - - return dct - - def objectify(self, dict_, context=None): # pylint: disable=empty-docstring - """ """ - context = super().objectify(dict_, context=context) - - for attr in dict_: - if self.inspector.has_property(attr): - continue # upstream logic handles these - - # try to process association proxy field - if get_association_proxy_column(self.inspector, attr): - value = dict_[attr] - if value is colander.null: - # `colander.null` is never an appropriate - # value to be placed on an SQLAlchemy object - # so we translate it into `None`. - value = None - setattr(context, attr, value) - - return context - - -def get_association_proxy(mapper, field): - """ - Return the association proxy "descriptor" corresponding to the - given field name, if it exists. - - :param mapper: SQLAlchemy mapper for the main class. - - :param field: Field name on the main class, which may (or may not) - be proxied via association. - - :returns: - :class:`~sqlalchemy:sqlalchemy.ext.associationproxy.AssociationProxy` - instance, or ``None``. - - Using the ``User.first_name`` (which proxies to - ``User.person.first_name``) example, this would return the proxy - descriptor for ``User.first_name``. - """ - try: - desc = getattr(mapper.all_orm_descriptors, field) - except AttributeError: - pass - else: - if desc.extension_type.name == "ASSOCIATION_PROXY": - return desc - return None - - -def get_association_proxy_target(mapper, field): - """ - Return the relationship property involved in the association proxy - for the given field, if applicable. - - :param mapper: SQLAlchemy mapper for the main class. - - :param field: Field name on the main class, which may (or may not) - be proxied via association. - - :returns: :class:`~sqlalchemy:sqlalchemy.orm.RelationshipProperty` - instance, or ``None``. - - Using the ``User.first_name`` (which proxies to - ``User.person.first_name``) example, this would return the - ``User.person`` relationship property. - """ - if proxy := get_association_proxy(mapper, field): - proxy_target = mapper.get_property(proxy.target_collection) - if ( - isinstance(proxy_target, orm.RelationshipProperty) - and not proxy_target.uselist - ): - return proxy_target - return None - - -def get_association_proxy_column(mapper, field): - """ - Return the target column property involved in the association - proxy for the given field, if applicable. - - :param mapper: SQLAlchemy mapper for the main class. - - :param field: Field name on the main class, which may (or may not) - be proxied via association. - - :returns: :class:`~sqlalchemy:sqlalchemy.orm.ColumnProperty` - instance, or ``None``. - - Using the ``User.first_name`` (which proxies to - ``User.person.first_name``) example, this would return the - ``Person.first_name`` column property. - """ - if proxy_target := get_association_proxy_target(mapper, field): - if proxy_target.mapper.has_property(field): - prop = proxy_target.mapper.get_property(field) - if isinstance(prop, orm.ColumnProperty) and isinstance( - prop.columns[0], sa.Column - ): - return prop - return None diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 077f51a..1d99a9f 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -2415,15 +2415,6 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth except TypeError: dct = dict(obj.__dict__) dct.pop("_sa_instance_state", None) - - # nb. inject association proxy(-like) fields if applicable - for field in self.columns: - if field not in dct: - try: - dct[field] = getattr(obj, field) - except AttributeError: - pass - return dct def get_vue_context(self): diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py index 9333b37..69096f7 100644 --- a/src/wuttaweb/views/users.py +++ b/src/wuttaweb/views/users.py @@ -198,13 +198,30 @@ class UserView(MasterView): # pylint: disable=abstract-method user = super().objectify(form) # maybe update person name - if "first_name" in form and "last_name" in form: + if "first_name" in form or "last_name" in form: first_name = data.get("first_name") last_name = data.get("last_name") - if first_name or last_name: - user.person.full_name = self.app.make_full_name(first_name, last_name) - else: - user.person = None + if self.creating and (first_name or last_name): + user.person = auth.make_person( + first_name=first_name, last_name=last_name + ) + elif self.editing: + if first_name or last_name: + if user.person: + person = user.person + if "first_name" in form: + person.first_name = first_name + if "last_name" in form: + person.last_name = last_name + person.full_name = self.app.make_full_name( + person.first_name, person.last_name + ) + else: + user.person = auth.make_person( + first_name=first_name, last_name=last_name + ) + elif user.person: + user.person = None # maybe set user password if "set_password" in form and data.get("set_password"): diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index 804b360..f4e998d 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -4,16 +4,11 @@ import os from unittest.mock import MagicMock, patch import sqlalchemy as sa -from sqlalchemy import orm -from sqlalchemy.ext.associationproxy import AssociationProxy -import deform import colander -from colanderalchemy import SQLAlchemySchemaNode +import deform from pyramid.renderers import get_renderer -from wuttjamaican.testing import DataTestCase - from wuttaweb.forms import base as mod, widgets from wuttaweb.grids import Grid from wuttaweb.testing import WebTestCase @@ -739,174 +734,3 @@ class TestForm(WebTestCase): self.assertNotIn("foo", dform) self.assertIn("bar", schema) self.assertIn("bar", dform) - - -class TestWuttaSchemaNode(WebTestCase): - - def test_add_nodes(self): - model = self.app.model - - # ColanderAlchemy does not add nodes for association proxy fields - schema = SQLAlchemySchemaNode( - model.User, includes=["username", "first_name", "last_name"] - ) - self.assertIn("username", schema) - self.assertNotIn("first_name", schema) - self.assertNotIn("last_name", schema) - - # but our custom class handles them okay - schema = mod.WuttaSchemaNode( - model.User, includes=["username", "first_name", "last_name"] - ) - self.assertIn("username", schema) - self.assertIn("first_name", schema) - self.assertIn("last_name", schema) - - def test_dictify(self): - model = self.app.model - - person = model.Person(first_name="Fred", last_name="Flintstone") - user = model.User(username="freddie", person=person) - user.foo_field = "bar" - - foo_field = colander.SchemaNode(colander.String()) - - # ColanderAlchemy does not include association proxy fields - schema = SQLAlchemySchemaNode( - model.User, includes=["username", "first_name", "last_name", foo_field] - ) - dct = schema.dictify(user) - self.assertIn("username", dct) - self.assertEqual(dct["username"], "freddie") - self.assertNotIn("first_name", dct) - self.assertNotIn("last_name", dct) - self.assertNotIn("foo_field", dct) - - # but our custom class handles them okay - schema = mod.WuttaSchemaNode( - model.User, includes=["username", "first_name", "last_name", foo_field] - ) - dct = schema.dictify(user) - self.assertIn("username", dct) - self.assertEqual(dct["username"], "freddie") - self.assertIn("first_name", dct) - self.assertEqual(dct["first_name"], "Fred") - self.assertIn("last_name", dct) - self.assertEqual(dct["last_name"], "Flintstone") - # nb. foo_field is not assn proxy, so not auto-dictified - self.assertNotIn("foo_field", dct) - - # also note special handling for null values on string proxy fields - user.last_name = None - dct = schema.dictify(user) - self.assertIn("first_name", dct) - self.assertIs(dct["first_name"], "Fred") - self.assertIn("last_name", dct) - self.assertIsNotNone(dct["last_name"]) - self.assertIs(dct["last_name"], colander.null) - - def test_objectify(self): - model = self.app.model - - # ColanderAlchemy does not set association proxy fields - schema = SQLAlchemySchemaNode( - model.User, - includes=["username", "first_name", "last_name"], - ) - user = schema.objectify( - { - "username": "freddie", - "first_name": "Fred", - "last_name": "Flintstone", - } - ) - self.assertIsInstance(user, model.User) - self.assertEqual(user.username, "freddie") - self.assertIsNone(user.person) - self.assertIsNone(user.first_name) - self.assertIsNone(user.last_name) - - # but our custom class handles them okay - schema = mod.WuttaSchemaNode( - model.User, - includes=["username", "first_name", "last_name"], - ) - user = schema.objectify( - { - "username": "freddie", - "first_name": "Fred", - "last_name": "Flintstone", - } - ) - self.assertIsInstance(user, model.User) - self.assertEqual(user.username, "freddie") - self.assertIsInstance(user.person, model.Person) - self.assertEqual(user.person.first_name, "Fred") - self.assertEqual(user.person.last_name, "Flintstone") - self.assertEqual(user.first_name, "Fred") - self.assertEqual(user.last_name, "Flintstone") - - # also note special handling for null values on string proxy fields - user = schema.objectify( - { - "username": "freddie", - "first_name": "Fred", - "last_name": colander.null, - } - ) - self.assertIsInstance(user, model.User) - self.assertEqual(user.username, "freddie") - self.assertIsInstance(user.person, model.Person) - self.assertEqual(user.person.first_name, "Fred") - self.assertIsNone(user.person.last_name) - self.assertEqual(user.first_name, "Fred") - self.assertIsNone(user.last_name) - - -class TestGetAssociationProxy(DataTestCase): - - def test_basic(self): - model = self.app.model - mapper = sa.inspect(model.User) - - # typical - proxy = mod.get_association_proxy(mapper, "first_name") - self.assertIsInstance(proxy, AssociationProxy) - self.assertEqual(proxy.target_collection, "person") - self.assertEqual(proxy.value_attr, "first_name") - - # no such proxy - proxy = mod.get_association_proxy(mapper, "blarg") - self.assertIsNone(proxy) - - -class TestGetAssociationProxyTarget(DataTestCase): - - def test_basic(self): - model = self.app.model - mapper = sa.inspect(model.User) - - # typical - target = mod.get_association_proxy_target(mapper, "first_name") - self.assertIsInstance(target, orm.RelationshipProperty) - self.assertIs(target, mapper.get_property("person")) - - # no such proxy - target = mod.get_association_proxy_target(mapper, "blarg") - self.assertIsNone(target) - - -class TestGetAssociationProxyColumn(DataTestCase): - - def test_basic(self): - model = self.app.model - mapper = sa.inspect(model.User) - - # typical - column = mod.get_association_proxy_column(mapper, "first_name") - self.assertIsInstance(column, orm.ColumnProperty) - self.assertIs(column, sa.inspect(model.Person).get_property("first_name")) - - # no such proxy - column = mod.get_association_proxy_column(mapper, "blarg") - self.assertIsNone(column) diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 89c0f50..d98282f 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -1923,24 +1923,10 @@ class TestGrid(WebTestCase): def __init__(self, **kw): self.__dict__.update(kw) - def __getattr__(self, name): - if name == "something": - return "else" - raise AttributeError(f"attr not found: {name}") - mock = MockSetting(**setting) dct = grid.object_to_dict(mock) self.assertIsInstance(dct, dict) self.assertEqual(dct, setting) - self.assertNotIn("something", dct) - - # test (mock) association proxy behavior - grid.columns.append("something") - grid.columns.append("another") - dct = grid.object_to_dict(mock) - self.assertIn("something", dct) - self.assertEqual(dct["something"], "else") - self.assertNotIn("another", dct) def test_get_vue_context(self): diff --git a/tests/views/test_users.py b/tests/views/test_users.py index 2fe08aa..c345b97 100644 --- a/tests/views/test_users.py +++ b/tests/views/test_users.py @@ -202,14 +202,12 @@ class TestUserView(WebTestCase): # form can update user password self.assertTrue(auth.check_user_password(barney, "testpass")) - self.assertFalse(auth.check_user_password(barney, "testpass2")) form = view.make_model_form(model_instance=barney) form.validated = {"username": "barney", "set_password": "testpass2"} with patch.object(view, "Session", return_value=self.session): user = view.objectify(form) self.assertIs(user, barney) self.assertTrue(auth.check_user_password(barney, "testpass2")) - self.assertFalse(auth.check_user_password(barney, "testpass")) # form can update user roles form = view.make_model_form(model_instance=barney) @@ -250,12 +248,10 @@ class TestUserView(WebTestCase): # nb. re-attach the person barney.person = self.session.query(model.Person).one() - # nb. first/last name was blanked out in last edit - self.assertEqual(barney.person.first_name, "") - self.assertEqual(barney.person.last_name, "") - self.assertEqual(barney.person.full_name, "Barney Rubble") - # person name is updated + self.assertEqual(barney.person.first_name, "Barney") + self.assertEqual(barney.person.last_name, "Rubble") + self.assertEqual(barney.person.full_name, "Barney Rubble") form = view.make_model_form(model_instance=barney) form.validated = { "username": "barney",