diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a39299..024d9ea 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/pyproject.toml b/pyproject.toml index 9cd1790..9f0f2fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"}] diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 69ba3a9..c7e0b37 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -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 diff --git a/src/wuttjamaican/batch.py b/src/wuttjamaican/batch.py index 98325ff..98e8aa9 100644 --- a/src/wuttjamaican/batch.py +++ b/src/wuttjamaican/batch.py @@ -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): diff --git a/src/wuttjamaican/db/model/batch.py b/src/wuttjamaican/db/model/batch.py index f6ba154..9b414b5 100644 --- a/src/wuttjamaican/db/model/batch.py +++ b/src/wuttjamaican/db/model/batch.py @@ -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) diff --git a/src/wuttjamaican/db/model/upgrades.py b/src/wuttjamaican/db/model/upgrades.py index 6893ba6..750a9a6 100644 --- a/src/wuttjamaican/db/model/upgrades.py +++ b/src/wuttjamaican/db/model/upgrades.py @@ -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. diff --git a/src/wuttjamaican/install.py b/src/wuttjamaican/install.py index cf9e3ac..28dfb60 100644 --- a/src/wuttjamaican/install.py +++ b/src/wuttjamaican/install.py @@ -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() diff --git a/src/wuttjamaican/util.py b/src/wuttjamaican/util.py index dfb9b13..161dbce 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -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. diff --git a/tests/test_app.py b/tests/test_app.py index 7168164..7c0f963 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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' diff --git a/tests/test_batch.py b/tests/test_batch.py index 6075fd0..7a2e643 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -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') diff --git a/tests/test_install.py b/tests/test_install.py index 78ae370..ba410b1 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -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 diff --git a/tests/test_util.py b/tests/test_util.py index 8015ff0..0f11918 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -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):