3
0
Fork 0

Compare commits

..

No commits in common. "028f8e767d5d9925bec8fa91feb39d2f47e556f1" and "4307b5a9eb4c04749d4b474ba965f2c017de1eab" have entirely different histories.

9 changed files with 30 additions and 388 deletions

View file

@ -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

View file

@ -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),

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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"):

View file

@ -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)

View file

@ -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):

View file

@ -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",