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/)
|
||||
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)
|
||||
|
||||
### Fix
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "WuttaWeb"
|
||||
version = "0.28.2"
|
||||
version = "0.29.0"
|
||||
description = "Web App for Wutta Framework"
|
||||
readme = "README.md"
|
||||
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
|
||||
|
|
@ -61,7 +61,7 @@ dependencies = [
|
|||
|
||||
|
||||
[project.optional-dependencies]
|
||||
continuum = ["Wutta-Continuum>=0.3.0"]
|
||||
continuum = ["Wutta-Continuum>=0.3.3"]
|
||||
docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"]
|
||||
tests = ["pylint", "pytest", "pytest-cov", "tox", "WebTest"]
|
||||
|
||||
|
|
|
|||
|
|
@ -457,6 +457,7 @@ class ObjectRef(colander.SchemaType):
|
|||
:returns: Instance of
|
||||
:class:`~wuttaweb.forms.widgets.ObjectRefWidget`.
|
||||
"""
|
||||
factory = kwargs.pop("factory", widgets.ObjectRefWidget)
|
||||
|
||||
if "values" not in kwargs:
|
||||
query = self.get_query()
|
||||
|
|
@ -469,7 +470,7 @@ class ObjectRef(colander.SchemaType):
|
|||
if "url" not in kwargs:
|
||||
kwargs["url"] = self.get_object_url
|
||||
|
||||
return widgets.ObjectRefWidget(self.request, **kwargs)
|
||||
return factory(self.request, **kwargs)
|
||||
|
||||
def get_object_url(self, obj):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -234,6 +234,9 @@ class WuttaDateWidget(DateInputWidget):
|
|||
""" """
|
||||
readonly = kw.get("readonly", self.readonly)
|
||||
if readonly and cstruct:
|
||||
try:
|
||||
dt = datetime.date.fromisoformat(cstruct)
|
||||
except ValueError:
|
||||
dt = datetime.datetime.fromisoformat(cstruct)
|
||||
return self.app.render_date(dt)
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,24 @@
|
|||
|
||||
</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>
|
||||
<div class="block" style="padding-left: 2rem;">
|
||||
|
||||
|
|
@ -348,6 +366,8 @@
|
|||
|
||||
ThisPageData.validators.push(ThisPage.methods.timezoneValidate)
|
||||
|
||||
ThisPageData.gridPagesizeOptions = ${json.dumps(grid_pagesize_options)|n}
|
||||
|
||||
ThisPageData.weblibs = ${json.dumps(weblibs or [])|n}
|
||||
|
||||
ThisPageData.editWebLibraryShowDialog = false
|
||||
|
|
|
|||
|
|
@ -1248,8 +1248,11 @@ class MasterView(View): # pylint: disable=too-many-public-methods
|
|||
|
||||
Default query will locate SQLAlchemy-Continuum ``transaction``
|
||||
records which are associated with versions of the given model
|
||||
instance. See also
|
||||
:func:`wutta-continuum:wutta_continuum.util.model_transaction_query()`.
|
||||
instance. See also:
|
||||
|
||||
* :meth:`get_version_joins()`
|
||||
* :meth:`normalize_version_joins()`
|
||||
* :func:`~wutta-continuum:wutta_continuum.util.model_transaction_query()`
|
||||
|
||||
: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()
|
||||
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())
|
||||
|
||||
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):
|
||||
"""
|
||||
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()
|
||||
route_prefix = self.get_route_prefix()
|
||||
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"]
|
||||
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.
|
||||
"""
|
||||
import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
|
||||
|
||||
session = self.Session()
|
||||
vercls = self.get_model_version_class()
|
||||
return (
|
||||
versions = []
|
||||
|
||||
# first get all versions for the model instance proper
|
||||
versions.extend(
|
||||
session.query(vercls)
|
||||
.filter(vercls.transaction == transaction)
|
||||
.filter(vercls.uuid == instance.uuid)
|
||||
.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
|
||||
##############################
|
||||
|
|
|
|||
|
|
@ -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.feedback.subject"},
|
||||
{"name": f"{self.config.appname}.email.feedback.to"},
|
||||
# grids
|
||||
{"name": "wuttaweb.grids.default_pagesize", "type": int},
|
||||
]
|
||||
|
||||
def getval(key):
|
||||
|
|
@ -282,6 +284,11 @@ class AppInfoView(MasterView): # pylint: disable=abstract-method
|
|||
handlers = [{"spec": spec} for spec in 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
|
||||
weblibs = self.get_weblibs()
|
||||
for key in weblibs:
|
||||
|
|
|
|||
|
|
@ -139,6 +139,9 @@ class TestWuttaDateWidget(WebTestCase):
|
|||
return mod.WuttaDateWidget(self.request, **kwargs)
|
||||
|
||||
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())
|
||||
field = self.make_field(node)
|
||||
|
||||
|
|
@ -156,7 +159,9 @@ class TestWuttaDateWidget(WebTestCase):
|
|||
|
||||
# now try again with datetime
|
||||
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
|
||||
result = widget.serialize(field, str(dt))
|
||||
|
|
@ -164,7 +169,7 @@ class TestWuttaDateWidget(WebTestCase):
|
|||
|
||||
# readonly is rendered per app convention
|
||||
result = widget.serialize(field, str(dt), readonly=True)
|
||||
self.assertEqual(result, "2025-01-15")
|
||||
self.assertEqual(result, "2025-01-14")
|
||||
|
||||
|
||||
class TestWuttaDateTimeWidget(WebTestCase):
|
||||
|
|
|
|||
|
|
@ -1785,6 +1785,8 @@ class TestMasterView(WebTestCase):
|
|||
kw = original_context(**kw)
|
||||
kw["menu_handlers"] = []
|
||||
kw["default_timezone"] = "UTC"
|
||||
kw["grid_pagesize_options"] = [10, 20, 50]
|
||||
kw["grid_pagesize_default"] = 20
|
||||
return kw
|
||||
|
||||
with patch.object(view, "configure_get_context", new=get_context):
|
||||
|
|
@ -2212,6 +2214,26 @@ class TestVersionedMasterView(VersionWebTestCase):
|
|||
view = self.make_view()
|
||||
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):
|
||||
model = self.app.model
|
||||
|
||||
|
|
@ -2300,7 +2322,9 @@ class TestVersionedMasterView(VersionWebTestCase):
|
|||
txncls = continuum.transaction_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.commit()
|
||||
|
||||
|
|
@ -2314,11 +2338,21 @@ class TestVersionedMasterView(VersionWebTestCase):
|
|||
with patch.object(mod.MasterView, "model_class", new=model.User):
|
||||
with patch.object(mod.MasterView, "Session", return_value=self.session):
|
||||
view = self.make_view()
|
||||
|
||||
# just one version if no joins are specified
|
||||
versions = view.get_relevant_versions(txn, user)
|
||||
self.assertEqual(len(versions), 1)
|
||||
version = versions[0]
|
||||
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):
|
||||
import sqlalchemy_continuum as continuum
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue