Compare commits
7 commits
a68df98297
...
48f9374724
Author | SHA1 | Date | |
---|---|---|---|
|
48f9374724 | ||
|
4479d9ff91 | ||
|
7e90888146 | ||
|
60a25ab342 | ||
|
b3ec7cb9b8 | ||
|
6d16aa0c02 | ||
|
c6a0ecd475 |
11
CHANGELOG.md
11
CHANGELOG.md
|
@ -5,6 +5,17 @@ All notable changes to WuttJamaican 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.19.2 (2025-01-06)
|
||||
|
||||
### Fix
|
||||
|
||||
- add `cascade_backrefs=False` for all ORM relationships
|
||||
- add `get_effective_rows()` method for batch handler
|
||||
- add `make_full_name()` function, app handler method
|
||||
- add batch handler logic to remove row
|
||||
- add `render_boolean`, `render_quantity` app handler methods
|
||||
- update post-install webapp command suggestion
|
||||
|
||||
## v0.19.1 (2024-12-28)
|
||||
|
||||
### Fix
|
||||
|
|
|
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "WuttJamaican"
|
||||
version = "0.19.1"
|
||||
version = "0.19.2"
|
||||
description = "Base package for Wutta Framework"
|
||||
readme = "README.md"
|
||||
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
|
||||
|
|
|
@ -33,7 +33,7 @@ import warnings
|
|||
import humanize
|
||||
|
||||
from wuttjamaican.util import (load_entry_points, load_object,
|
||||
make_title, make_uuid, make_true_uuid,
|
||||
make_title, make_full_name, make_uuid, make_true_uuid,
|
||||
progress_loop, resource_path, simple_error)
|
||||
|
||||
|
||||
|
@ -495,6 +495,15 @@ class AppHandler:
|
|||
"""
|
||||
return make_title(text)
|
||||
|
||||
def make_full_name(self, *parts):
|
||||
"""
|
||||
Make a "full name" from the given parts.
|
||||
|
||||
This is a convenience wrapper around
|
||||
:func:`~wuttjamaican.util.make_full_name()`.
|
||||
"""
|
||||
return make_full_name(*parts)
|
||||
|
||||
def make_true_uuid(self):
|
||||
"""
|
||||
Generate a new UUID value.
|
||||
|
@ -676,7 +685,20 @@ class AppHandler:
|
|||
# common value renderers
|
||||
##############################
|
||||
|
||||
def render_currency(self, value, scale=2, **kwargs):
|
||||
def render_boolean(self, value):
|
||||
"""
|
||||
Render a boolean value for display.
|
||||
|
||||
:param value: A boolean, or ``None``.
|
||||
|
||||
:returns: Display string for the value.
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
|
||||
return "Yes" if value else "No"
|
||||
|
||||
def render_currency(self, value, scale=2):
|
||||
"""
|
||||
Return a human-friendly display string for the given currency
|
||||
value, e.g. ``Decimal('4.20')`` becomes ``"$4.20"``.
|
||||
|
@ -749,6 +771,28 @@ class AppHandler:
|
|||
"""
|
||||
return simple_error(error)
|
||||
|
||||
def render_quantity(self, value, empty_zero=False):
|
||||
"""
|
||||
Return a human-friendly display string for the given quantity
|
||||
value, e.g. ``1.000`` becomes ``"1"``.
|
||||
|
||||
:param value: The quantity to be rendered.
|
||||
|
||||
:param empty_zero: Affects the display when value equals zero.
|
||||
If false (the default), will return ``'0'``; if true then
|
||||
it returns empty string.
|
||||
|
||||
:returns: Display string for the quantity.
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
if int(value) == value:
|
||||
value = int(value)
|
||||
if empty_zero and value == 0:
|
||||
return ''
|
||||
return str(value)
|
||||
return str(value).rstrip('0')
|
||||
|
||||
def render_time_ago(self, value):
|
||||
"""
|
||||
Return a human-friendly string, indicating how long ago
|
||||
|
|
|
@ -296,6 +296,54 @@ class BatchHandler(GenericHandler):
|
|||
that depending on the workflow.
|
||||
"""
|
||||
|
||||
def do_remove_row(self, row):
|
||||
"""
|
||||
Remove a row from its batch. This will:
|
||||
|
||||
* call :meth:`remove_row()`
|
||||
* decrement the batch
|
||||
:attr:`~wuttjamaican.db.model.batch.BatchMixin.row_count`
|
||||
* call :meth:`refresh_batch_status()`
|
||||
|
||||
So, callers should use ``do_remove_row()``, but subclass
|
||||
should (usually) override :meth:`remove_row()` etc.
|
||||
"""
|
||||
batch = row.batch
|
||||
|
||||
self.remove_row(row)
|
||||
|
||||
if batch.row_count is not None:
|
||||
batch.row_count -= 1
|
||||
|
||||
self.refresh_batch_status(batch)
|
||||
|
||||
def remove_row(self, row):
|
||||
"""
|
||||
Remove a row from its batch.
|
||||
|
||||
Callers should use :meth:`do_remove_row()` instead, which
|
||||
calls this method automatically.
|
||||
|
||||
Subclass can override this method; the default logic just
|
||||
deletes the row.
|
||||
"""
|
||||
session = self.app.get_session(row)
|
||||
session.delete(row)
|
||||
|
||||
def refresh_batch_status(self, batch):
|
||||
"""
|
||||
Update the batch status as needed.
|
||||
|
||||
This method is called when some row data has changed for the
|
||||
batch, e.g. from :meth:`do_remove_row()`.
|
||||
|
||||
It does nothing by default; subclass may override to set these
|
||||
attributes on the batch:
|
||||
|
||||
* :attr:`~wuttjamaican.db.model.batch.BatchMixin.status_code`
|
||||
* :attr:`~wuttjamaican.db.model.batch.BatchMixin.status_text`
|
||||
"""
|
||||
|
||||
def why_not_execute(self, batch, user=None, **kwargs):
|
||||
"""
|
||||
Returns text indicating the reason (if any) that a given batch
|
||||
|
@ -368,6 +416,19 @@ class BatchHandler(GenericHandler):
|
|||
:returns: Markdown text describing batch execution.
|
||||
"""
|
||||
|
||||
def get_effective_rows(self, batch):
|
||||
"""
|
||||
This should return a list of "effective" rows for the batch.
|
||||
|
||||
In other words, which rows should be "acted upon" when the
|
||||
batch is executed.
|
||||
|
||||
The default logic returns the full list of batch
|
||||
:attr:`~wuttjamaican.db.model.batch.BatchMixin.rows`, but
|
||||
subclass may need to filter by status code etc.
|
||||
"""
|
||||
return batch.rows
|
||||
|
||||
def do_execute(self, batch, user, progress=None, **kwargs):
|
||||
"""
|
||||
Perform the execution steps for a batch.
|
||||
|
@ -395,6 +456,9 @@ class BatchHandler(GenericHandler):
|
|||
:param \**kwargs: Additional kwargs as needed. These are
|
||||
passed as-is to :meth:`why_not_execute()` and
|
||||
:meth:`execute()`.
|
||||
|
||||
:returns: Whatever was returned from :meth:`execute()` - often
|
||||
``None``.
|
||||
"""
|
||||
if batch.executed:
|
||||
raise ValueError(f"batch has already been executed: {batch}")
|
||||
|
@ -403,9 +467,10 @@ class BatchHandler(GenericHandler):
|
|||
if reason:
|
||||
raise RuntimeError(f"batch execution not allowed: {reason}")
|
||||
|
||||
self.execute(batch, user=user, progress=progress, **kwargs)
|
||||
result = self.execute(batch, user=user, progress=progress, **kwargs)
|
||||
batch.executed = datetime.datetime.now()
|
||||
batch.executed_by = user
|
||||
return result
|
||||
|
||||
def execute(self, batch, user=None, progress=None, **kwargs):
|
||||
"""
|
||||
|
@ -428,6 +493,10 @@ class BatchHandler(GenericHandler):
|
|||
:param \**kwargs: Additional kwargs which may affect the batch
|
||||
execution behavior. There are none by default, but some
|
||||
handlers may declare/use them.
|
||||
|
||||
:returns: ``None`` by default, but subclass can return
|
||||
whatever it likes, in which case that will be also returned
|
||||
to the caller from :meth:`do_execute()`.
|
||||
"""
|
||||
|
||||
def do_delete(self, batch, user, dry_run=False, progress=None, **kwargs):
|
||||
|
|
|
@ -182,7 +182,6 @@ class BatchMixin:
|
|||
|
||||
Reference to the :class:`~wuttjamaican.db.model.auth.User` who
|
||||
executed the batch.
|
||||
|
||||
"""
|
||||
|
||||
@declared_attr
|
||||
|
@ -231,7 +230,8 @@ class BatchMixin:
|
|||
return orm.relationship(
|
||||
User,
|
||||
primaryjoin=lambda: User.uuid == cls.created_by_uuid,
|
||||
foreign_keys=lambda: [cls.created_by_uuid])
|
||||
foreign_keys=lambda: [cls.created_by_uuid],
|
||||
cascade_backrefs=False)
|
||||
|
||||
|
||||
executed = sa.Column(sa.DateTime(timezone=True), nullable=True)
|
||||
|
@ -242,7 +242,8 @@ class BatchMixin:
|
|||
return orm.relationship(
|
||||
User,
|
||||
primaryjoin=lambda: User.uuid == cls.executed_by_uuid,
|
||||
foreign_keys=lambda: [cls.executed_by_uuid])
|
||||
foreign_keys=lambda: [cls.executed_by_uuid],
|
||||
cascade_backrefs=False)
|
||||
|
||||
def __repr__(self):
|
||||
cls = self.__class__.__name__
|
||||
|
@ -404,12 +405,14 @@ class BatchRowMixin:
|
|||
order_by=lambda: row_class.sequence,
|
||||
collection_class=ordering_list('sequence', count_from=1),
|
||||
cascade='all, delete-orphan',
|
||||
cascade_backrefs=False,
|
||||
back_populates='batch')
|
||||
|
||||
# now, here's the `BatchRow.batch`
|
||||
return orm.relationship(
|
||||
batch_class,
|
||||
back_populates='rows')
|
||||
back_populates='rows',
|
||||
cascade_backrefs=False)
|
||||
|
||||
sequence = sa.Column(sa.Integer(), nullable=False)
|
||||
|
||||
|
|
|
@ -52,6 +52,7 @@ class Upgrade(Base):
|
|||
created_by = orm.relationship(
|
||||
'User',
|
||||
foreign_keys=[created_by_uuid],
|
||||
cascade_backrefs=False,
|
||||
doc="""
|
||||
:class:`~wuttjamaican.db.model.auth.User` who created the
|
||||
upgrade record.
|
||||
|
@ -82,6 +83,7 @@ class Upgrade(Base):
|
|||
executed_by = orm.relationship(
|
||||
'User',
|
||||
foreign_keys=[executed_by_uuid],
|
||||
cascade_backrefs=False,
|
||||
doc="""
|
||||
:class:`~wuttjamaican.db.model.auth.User` who executed the
|
||||
upgrade.
|
||||
|
|
|
@ -461,7 +461,7 @@ class InstallHandler(GenericHandler):
|
|||
if self.schema_installed:
|
||||
self.rprint("\n\tyou can run the web app with:")
|
||||
self.rprint(f"\n\t[blue]cd {sys.prefix}[/blue]")
|
||||
self.rprint("\t[blue]bin/pserve file+ini:app/web.conf[/blue]")
|
||||
self.rprint("\t[blue]bin/wutta -c app/web.conf webapp -r[/blue]")
|
||||
|
||||
self.rprint()
|
||||
|
||||
|
|
|
@ -171,6 +171,26 @@ def make_title(text):
|
|||
return ' '.join([x.capitalize() for x in words])
|
||||
|
||||
|
||||
def make_full_name(*parts):
|
||||
"""
|
||||
Make a "full name" from the given parts.
|
||||
|
||||
:param \*parts: Distinct name values which should be joined
|
||||
together to make the full name.
|
||||
|
||||
:returns: The full name.
|
||||
|
||||
For instance::
|
||||
|
||||
make_full_name('First', '', 'Last', 'Suffix')
|
||||
# => "First Last Suffix"
|
||||
"""
|
||||
parts = [(part or '').strip()
|
||||
for part in parts]
|
||||
parts = [part for part in parts if part]
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
def make_true_uuid():
|
||||
"""
|
||||
Generate a new v7 UUID value.
|
||||
|
|
|
@ -384,6 +384,10 @@ app_title = WuttaTest
|
|||
text = self.app.make_title('foo_bar')
|
||||
self.assertEqual(text, "Foo Bar")
|
||||
|
||||
def test_make_full_name(self):
|
||||
name = self.app.make_full_name('Fred', '', 'Flintstone', '')
|
||||
self.assertEqual(name, "Fred Flintstone")
|
||||
|
||||
def test_make_uuid(self):
|
||||
uuid = self.app.make_uuid()
|
||||
self.assertEqual(len(uuid), 32)
|
||||
|
@ -423,6 +427,17 @@ app_title = WuttaTest
|
|||
session = self.app.get_session(user)
|
||||
self.assertIs(session, mysession)
|
||||
|
||||
def test_render_boolean(self):
|
||||
|
||||
# null
|
||||
self.assertEqual(self.app.render_boolean(None), "")
|
||||
|
||||
# true
|
||||
self.assertEqual(self.app.render_boolean(True), "Yes")
|
||||
|
||||
# false
|
||||
self.assertEqual(self.app.render_boolean(False), "No")
|
||||
|
||||
def test_render_currency(self):
|
||||
|
||||
# null
|
||||
|
@ -460,7 +475,7 @@ app_title = WuttaTest
|
|||
dt = datetime.datetime(2024, 12, 11, 8, 30, tzinfo=datetime.timezone.utc)
|
||||
self.assertEqual(self.app.render_datetime(dt), '2024-12-11 08:30+0000')
|
||||
|
||||
def test_simple_error(self):
|
||||
def test_render_error(self):
|
||||
|
||||
# with description
|
||||
try:
|
||||
|
@ -476,6 +491,23 @@ app_title = WuttaTest
|
|||
result = self.app.render_error(error)
|
||||
self.assertEqual(result, "RuntimeError")
|
||||
|
||||
def test_render_quantity(self):
|
||||
|
||||
# null
|
||||
self.assertEqual(self.app.render_quantity(None), "")
|
||||
|
||||
# integer decimals become integers
|
||||
value = decimal.Decimal('1.000')
|
||||
self.assertEqual(self.app.render_quantity(value), "1")
|
||||
|
||||
# but decimal places are preserved
|
||||
value = decimal.Decimal('1.234')
|
||||
self.assertEqual(self.app.render_quantity(value), "1.234")
|
||||
|
||||
# zero can be empty string
|
||||
self.assertEqual(self.app.render_quantity(0), "0")
|
||||
self.assertEqual(self.app.render_quantity(0, empty_zero=True), "")
|
||||
|
||||
def test_render_time_ago(self):
|
||||
with patch.object(mod, 'humanize') as humanize:
|
||||
humanize.naturaltime.return_value = 'now'
|
||||
|
|
|
@ -115,6 +115,41 @@ else:
|
|||
handler.add_row(batch, row)
|
||||
self.assertEqual(batch.row_count, 1)
|
||||
|
||||
def test_remove_row(self):
|
||||
model = self.app.model
|
||||
handler = self.make_handler()
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
batch = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch)
|
||||
row = handler.make_row()
|
||||
handler.add_row(batch, row)
|
||||
self.session.flush()
|
||||
self.assertEqual(batch.row_count, 1)
|
||||
handler.do_remove_row(row)
|
||||
self.session.flush()
|
||||
self.assertEqual(batch.row_count, 0)
|
||||
|
||||
def test_get_effective_rows(self):
|
||||
model = self.app.model
|
||||
handler = self.make_handler()
|
||||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
batch = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch)
|
||||
self.session.flush()
|
||||
|
||||
self.assertEqual(handler.get_effective_rows(batch), [])
|
||||
|
||||
row = handler.make_row()
|
||||
handler.add_row(batch, row)
|
||||
self.session.flush()
|
||||
|
||||
rows = handler.get_effective_rows(batch)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertIs(rows[0], row)
|
||||
|
||||
def test_do_execute(self):
|
||||
model = self.app.model
|
||||
user = model.User(username='barney')
|
||||
|
|
|
@ -214,7 +214,7 @@ default.url = {db_url}
|
|||
handler.schema_installed = True
|
||||
handler.show_goodbye()
|
||||
rprint.assert_any_call("\n\t[bold green]initial setup is complete![/bold green]")
|
||||
rprint.assert_any_call("\t[blue]bin/pserve file+ini:app/web.conf[/blue]")
|
||||
rprint.assert_any_call("\t[blue]bin/wutta -c app/web.conf webapp -r[/blue]")
|
||||
|
||||
def test_require_prompt_toolkit_installed(self):
|
||||
# nb. this assumes we *do* have prompt_toolkit installed
|
||||
|
|
|
@ -263,6 +263,13 @@ class TestMakeTitle(TestCase):
|
|||
self.assertEqual(text, "Foo Bar")
|
||||
|
||||
|
||||
class TestMakeFullName(TestCase):
|
||||
|
||||
def test_basic(self):
|
||||
name = mod.make_full_name('Fred', '', 'Flintstone', '')
|
||||
self.assertEqual(name, 'Fred Flintstone')
|
||||
|
||||
|
||||
class TestProgressLoop(TestCase):
|
||||
|
||||
def test_basic(self):
|
||||
|
|
Loading…
Reference in a new issue