Compare commits
5 commits
205a1f7a65
...
de7fc4dcf0
| Author | SHA1 | Date | |
|---|---|---|---|
| de7fc4dcf0 | |||
| cc52c708db | |||
| 7cfe6e15f4 | |||
| 590441c0be | |||
| cba8e4774d |
9 changed files with 178 additions and 12 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
##############################
|
##############################
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue