3
0
Fork 0

Compare commits

...

5 commits

9 changed files with 178 additions and 12 deletions

View file

@ -5,6 +5,20 @@ 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/) 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). and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.29.0 (2026-03-04)
### Feat
- allow widget factory override for `ObjectRef` schema type
- add way to declare related versions for history in MasterView
### Fix
- expose default grid pagesize in appinfo config
- fix timezone edge case for `WuttaDateWidget`
- sort roles by name when viewing user
- make pylint happy
## v0.28.2 (2026-02-25) ## v0.28.2 (2026-02-25)
### Fix ### Fix

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttaWeb" name = "WuttaWeb"
version = "0.28.2" version = "0.29.0"
description = "Web App for Wutta Framework" description = "Web App for Wutta Framework"
readme = "README.md" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@ -61,7 +61,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
continuum = ["Wutta-Continuum>=0.3.0"] continuum = ["Wutta-Continuum>=0.3.3"]
docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"] docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"]
tests = ["pylint", "pytest", "pytest-cov", "tox", "WebTest"] tests = ["pylint", "pytest", "pytest-cov", "tox", "WebTest"]

View file

@ -457,6 +457,7 @@ class ObjectRef(colander.SchemaType):
:returns: Instance of :returns: Instance of
:class:`~wuttaweb.forms.widgets.ObjectRefWidget`. :class:`~wuttaweb.forms.widgets.ObjectRefWidget`.
""" """
factory = kwargs.pop("factory", widgets.ObjectRefWidget)
if "values" not in kwargs: if "values" not in kwargs:
query = self.get_query() query = self.get_query()
@ -469,7 +470,7 @@ class ObjectRef(colander.SchemaType):
if "url" not in kwargs: if "url" not in kwargs:
kwargs["url"] = self.get_object_url kwargs["url"] = self.get_object_url
return widgets.ObjectRefWidget(self.request, **kwargs) return factory(self.request, **kwargs)
def get_object_url(self, obj): def get_object_url(self, obj):
""" """

View file

@ -234,7 +234,10 @@ class WuttaDateWidget(DateInputWidget):
""" """ """ """
readonly = kw.get("readonly", self.readonly) readonly = kw.get("readonly", self.readonly)
if readonly and cstruct: if readonly and cstruct:
dt = datetime.datetime.fromisoformat(cstruct) try:
dt = datetime.date.fromisoformat(cstruct)
except ValueError:
dt = datetime.datetime.fromisoformat(cstruct)
return self.app.render_date(dt) return self.app.render_date(dt)
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)

View file

@ -158,6 +158,24 @@
</div> </div>
<h3 class="block is-size-3">Grids</h3>
<div class="block" style="padding-left: 2rem; width: 50%;">
<b-field label="Default Page Size"
:message="simpleSettings['wuttaweb.grids.default_pagesize'] ? '' : 'Current default page size is: ${grid_pagesize_default}'">
<b-select name="wuttaweb.grids.default_pagesize"
v-model="simpleSettings['wuttaweb.grids.default_pagesize']"
@input="settingsNeedSaved = true">
<option :value="null">(use default)</option>
<option v-for="option in gridPagesizeOptions"
:value="option">
{{ option }}
</option>
</b-select>
</b-field>
</div>
<h3 class="block is-size-3">Web Libraries</h3> <h3 class="block is-size-3">Web Libraries</h3>
<div class="block" style="padding-left: 2rem;"> <div class="block" style="padding-left: 2rem;">
@ -348,6 +366,8 @@
ThisPageData.validators.push(ThisPage.methods.timezoneValidate) ThisPageData.validators.push(ThisPage.methods.timezoneValidate)
ThisPageData.gridPagesizeOptions = ${json.dumps(grid_pagesize_options)|n}
ThisPageData.weblibs = ${json.dumps(weblibs or [])|n} ThisPageData.weblibs = ${json.dumps(weblibs or [])|n}
ThisPageData.editWebLibraryShowDialog = false ThisPageData.editWebLibraryShowDialog = false

View file

@ -1248,8 +1248,11 @@ class MasterView(View): # pylint: disable=too-many-public-methods
Default query will locate SQLAlchemy-Continuum ``transaction`` Default query will locate SQLAlchemy-Continuum ``transaction``
records which are associated with versions of the given model records which are associated with versions of the given model
instance. See also instance. See also:
:func:`wutta-continuum:wutta_continuum.util.model_transaction_query()`.
* :meth:`get_version_joins()`
* :meth:`normalize_version_joins()`
* :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`
:returns: :class:`~sqlalchemy:sqlalchemy.orm.Query` instance :returns: :class:`~sqlalchemy:sqlalchemy.orm.Query` instance
""" """
@ -1260,9 +1263,67 @@ class MasterView(View): # pylint: disable=too-many-public-methods
model_class = self.get_model_class() model_class = self.get_model_class()
txncls = continuum.transaction_class(model_class) txncls = continuum.transaction_class(model_class)
query = model_transaction_query(instance) query = model_transaction_query(instance, joins=self.normalize_version_joins())
return query.order_by(txncls.issued_at.desc()) return query.order_by(txncls.issued_at.desc())
def get_version_joins(self):
"""
Override this method to declare additional version tables
which should be joined when showing the overall revision
history for a given model instance.
Note that whatever this method returns, will be ran through
:meth:`normalize_version_joins()` before being passed along to
:func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`.
:returns: List of version joins info as described below.
In the simple scenario where an "extension" table is involved,
e.g. a ``UserExtension`` table::
def get_version_joins(self):
model = self.app.model
return super().get_version_joins() + [
model.UserExtension,
]
In the case where a secondary table is "related" to the main
model table, but not a standard extension (using the
``User.person`` relationship as example)::
def get_version_joins(self):
model = self.app.model
return super().get_version_joins() + [
(model.Person, "uuid", "person_uuid"),
]
See also :meth:`get_version_grid_data()`.
"""
return []
def normalize_version_joins(self):
"""
This method calls :meth:`get_version_joins()` and normalizes
the result, which will then get passed along to
:func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`.
Subclass should (generally) not override this, but instead
override :meth:`get_version_joins()`.
Each element in the return value (list) will be a 3-tuple
conforming to what is needed for the query function.
See also :meth:`get_version_grid_data()`.
:returns: List of version joins info.
"""
joins = []
for join in self.get_version_joins():
if not isinstance(join, tuple):
join = (join, "uuid", "uuid")
joins.append(join)
return joins
def configure_version_grid(self, g): def configure_version_grid(self, g):
""" """
Configure the grid for the :meth:`view_versions()` view. Configure the grid for the :meth:`view_versions()` view.
@ -1326,7 +1387,9 @@ class MasterView(View): # pylint: disable=too-many-public-methods
model_class = self.get_model_class() model_class = self.get_model_class()
route_prefix = self.get_route_prefix() route_prefix = self.get_route_prefix()
txncls = continuum.transaction_class(model_class) txncls = continuum.transaction_class(model_class)
transactions = model_transaction_query(instance) transactions = model_transaction_query(
instance, joins=self.normalize_version_joins()
)
txnid = self.request.matchdict["txnid"] txnid = self.request.matchdict["txnid"]
txn = transactions.filter(txncls.id == txnid).first() txn = transactions.filter(txncls.id == txnid).first()
@ -1408,15 +1471,34 @@ class MasterView(View): # pylint: disable=too-many-public-methods
:returns: List of version records. :returns: List of version records.
""" """
import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
session = self.Session() session = self.Session()
vercls = self.get_model_version_class() vercls = self.get_model_version_class()
return ( versions = []
# first get all versions for the model instance proper
versions.extend(
session.query(vercls) session.query(vercls)
.filter(vercls.transaction == transaction) .filter(vercls.transaction == transaction)
.filter(vercls.uuid == instance.uuid) .filter(vercls.uuid == instance.uuid)
.all() .all()
) )
# then get all related versions, per declared joins
for child_class, foreign_attr, primary_attr in self.normalize_version_joins():
child_vercls = continuum.version_class(child_class)
versions.extend(
session.query(child_vercls)
.filter(child_vercls.transaction == transaction)
.filter(
getattr(child_vercls, foreign_attr)
== getattr(instance, primary_attr)
)
)
return versions
############################## ##############################
# autocomplete methods # autocomplete methods
############################## ##############################

View file

@ -228,6 +228,8 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method
{"name": f"{self.config.appname}.email.default.to"}, {"name": f"{self.config.appname}.email.default.to"},
{"name": f"{self.config.appname}.email.feedback.subject"}, {"name": f"{self.config.appname}.email.feedback.subject"},
{"name": f"{self.config.appname}.email.feedback.to"}, {"name": f"{self.config.appname}.email.feedback.to"},
# grids
{"name": "wuttaweb.grids.default_pagesize", "type": int},
] ]
def getval(key): def getval(key):
@ -282,6 +284,11 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method
handlers = [{"spec": spec} for spec in handlers] handlers = [{"spec": spec} for spec in handlers]
context["menu_handlers"] = handlers context["menu_handlers"] = handlers
# add pagesize options
g = self.make_grid()
context["grid_pagesize_options"] = g.get_pagesize_options()
context["grid_pagesize_default"] = g.get_pagesize()
# add `weblibs` to context, based on config values # add `weblibs` to context, based on config values
weblibs = self.get_weblibs() weblibs = self.get_weblibs()
for key in weblibs: for key in weblibs:

View file

@ -139,6 +139,9 @@ class TestWuttaDateWidget(WebTestCase):
return mod.WuttaDateWidget(self.request, **kwargs) return mod.WuttaDateWidget(self.request, **kwargs)
def test_serialize(self): def test_serialize(self):
self.config.setdefault("wuttatest.timezone.default", "America/Los_Angeles")
tzlocal = get_timezone_by_name("America/Los_Angeles")
node = colander.SchemaNode(colander.Date()) node = colander.SchemaNode(colander.Date())
field = self.make_field(node) field = self.make_field(node)
@ -156,7 +159,9 @@ class TestWuttaDateWidget(WebTestCase):
# now try again with datetime # now try again with datetime
widget = self.make_widget() widget = self.make_widget()
dt = datetime.datetime(2025, 1, 15, 8, 35) # nb. local zone is Los_Angeles but this is presumed to be "naive UTC"
# hence local date is 2025-01-14
dt = datetime.datetime(2025, 1, 15, 4, 35)
# editable widget has normal picker html # editable widget has normal picker html
result = widget.serialize(field, str(dt)) result = widget.serialize(field, str(dt))
@ -164,7 +169,7 @@ class TestWuttaDateWidget(WebTestCase):
# readonly is rendered per app convention # readonly is rendered per app convention
result = widget.serialize(field, str(dt), readonly=True) result = widget.serialize(field, str(dt), readonly=True)
self.assertEqual(result, "2025-01-15") self.assertEqual(result, "2025-01-14")
class TestWuttaDateTimeWidget(WebTestCase): class TestWuttaDateTimeWidget(WebTestCase):

View file

@ -1785,6 +1785,8 @@ class TestMasterView(WebTestCase):
kw = original_context(**kw) kw = original_context(**kw)
kw["menu_handlers"] = [] kw["menu_handlers"] = []
kw["default_timezone"] = "UTC" kw["default_timezone"] = "UTC"
kw["grid_pagesize_options"] = [10, 20, 50]
kw["grid_pagesize_default"] = 20
return kw return kw
with patch.object(view, "configure_get_context", new=get_context): with patch.object(view, "configure_get_context", new=get_context):
@ -2212,6 +2214,26 @@ class TestVersionedMasterView(VersionWebTestCase):
view = self.make_view() view = self.make_view()
self.assertEqual(view.get_version_grid_columns(), ["issued_at", "user"]) self.assertEqual(view.get_version_grid_columns(), ["issued_at", "user"])
def test_get_version_joins(self):
view = self.make_view()
self.assertEqual(view.get_version_joins(), [])
def test_normalize_version_joins(self):
model = self.app.model
view = self.make_view()
joins = [(model.Person, "uuid", "person_uuid")]
with patch.object(view, "get_version_joins", return_value=joins):
normal = view.normalize_version_joins()
self.assertEqual(normal, joins)
self.assertEqual(normal, [(model.Person, "uuid", "person_uuid")])
joins = [model.Person]
with patch.object(view, "get_version_joins", return_value=joins):
normal = view.normalize_version_joins()
self.assertNotEqual(normal, joins)
self.assertEqual(normal, [(model.Person, "uuid", "uuid")])
def test_get_version_grid_data(self): def test_get_version_grid_data(self):
model = self.app.model model = self.app.model
@ -2300,7 +2322,9 @@ class TestVersionedMasterView(VersionWebTestCase):
txncls = continuum.transaction_class(model.User) txncls = continuum.transaction_class(model.User)
vercls = continuum.version_class(model.User) vercls = continuum.version_class(model.User)
user = model.User(username="fred") person = model.Person(full_name="Fred Flintstone")
self.session.add(person)
user = model.User(username="fred", person=person)
self.session.add(user) self.session.add(user)
self.session.commit() self.session.commit()
@ -2314,11 +2338,21 @@ class TestVersionedMasterView(VersionWebTestCase):
with patch.object(mod.MasterView, "model_class", new=model.User): with patch.object(mod.MasterView, "model_class", new=model.User):
with patch.object(mod.MasterView, "Session", return_value=self.session): with patch.object(mod.MasterView, "Session", return_value=self.session):
view = self.make_view() view = self.make_view()
# just one version if no joins are specified
versions = view.get_relevant_versions(txn, user) versions = view.get_relevant_versions(txn, user)
self.assertEqual(len(versions), 1) self.assertEqual(len(versions), 1)
version = versions[0] version = versions[0]
self.assertIsInstance(version, vercls) self.assertIsInstance(version, vercls)
# but two versions if we specify join
joins = [(model.Person, "uuid", "person_uuid")]
with patch.object(view, "get_version_joins", return_value=joins):
versions = view.get_relevant_versions(txn, user)
self.assertEqual(len(versions), 2)
types = sorted([v.__class__.__name__ for v in versions])
self.assertEqual(types, ["PersonVersion", "UserVersion"])
def test_view_version(self): def test_view_version(self):
import sqlalchemy_continuum as continuum import sqlalchemy_continuum as continuum