From 10610d58090409e49dd79d888b526c1121db0b38 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 4 Dec 2025 22:57:12 -0600 Subject: [PATCH] fix: add basic `create_row()` support, esp. for batch views --- src/wuttaweb/grids/base.py | 6 +- src/wuttaweb/templates/master/create_row.mako | 4 + src/wuttaweb/views/batch.py | 92 +++++- src/wuttaweb/views/master.py | 304 +++++++++++++++++- tests/views/test_batch.py | 139 ++++++-- tests/views/test_master.py | 199 +++++++++++- 6 files changed, 696 insertions(+), 48 deletions(-) create mode 100644 src/wuttaweb/templates/master/create_row.mako diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 9461b23..81c92ae 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -84,7 +84,8 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth .. attribute:: vue_tagname String name for Vue component tag. By default this is - ``'wutta-grid'``. See also :meth:`render_vue_tag()`. + ``'wutta-grid'``. See also :meth:`render_vue_tag()` + and :attr:`vue_component`. .. attribute:: model_class @@ -841,8 +842,9 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth def set_tools(self, tools): """ Set the :attr:`tools` attribute using the given tools collection. - This will normalize the list/dict to desired internal format. + + See also :meth:`add_tool()`. """ if tools and isinstance(tools, list): if not any(isinstance(t, (tuple, list)) for t in tools): diff --git a/src/wuttaweb/templates/master/create_row.mako b/src/wuttaweb/templates/master/create_row.mako new file mode 100644 index 0000000..0c420c6 --- /dev/null +++ b/src/wuttaweb/templates/master/create_row.mako @@ -0,0 +1,4 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> + +<%def name="title()">New ${row_model_title} diff --git a/src/wuttaweb/views/batch.py b/src/wuttaweb/views/batch.py index 79bea75..3c6578d 100644 --- a/src/wuttaweb/views/batch.py +++ b/src/wuttaweb/views/batch.py @@ -61,7 +61,7 @@ class BatchMasterView(MasterView): sort_defaults = ("id", "desc") has_rows = True - rows_title = "Batch Rows" + row_model_title = "Batch Row" rows_sort_defaults = "sequence" row_labels = { @@ -221,6 +221,14 @@ class BatchMasterView(MasterView): f.set_node("executed_by", UserRef(self.request)) f.set_readonly("executed_by") + def is_editable(self, batch): + """ + This overrides the parent method + :meth:`~wuttaweb.views.master.MasterView.is_editable()` to + return ``False`` if the batch has already been executed. + """ + return not batch.executed + def objectify(self, form, **kwargs): """ We override the default logic here, to invoke @@ -231,7 +239,10 @@ class BatchMasterView(MasterView): :param \\**kwargs: Additional kwargs will be passed as-is to the ``make_batch()`` call. """ - if self.creating: + model = self.app.model + + # need special handling when creating new batch + if self.creating and issubclass(form.model_class, model.BatchMixin): # first get the "normal" objectified batch. this will have # all attributes set correctly per the form data, but will @@ -255,7 +266,7 @@ class BatchMasterView(MasterView): # finally let batch handler make the "real" batch return self.batch_handler.make_batch(self.Session(), **kw) - # when not creating, normal logic is fine + # otherwise normal logic is fine return super().objectify(form) def redirect_after_create(self, obj): @@ -381,11 +392,22 @@ class BatchMasterView(MasterView): @classmethod def get_row_model_class(cls): # pylint: disable=empty-docstring """ """ - if hasattr(cls, "row_model_class"): + if cls.row_model_class: return cls.row_model_class model_class = cls.get_model_class() - return model_class.__row_class__ + if model_class and hasattr(model_class, "__row_class__"): + return model_class.__row_class__ + + return None + + def get_row_parent(self, row): + """ + This overrides the parent method + :meth:`~wuttaweb.views.master.MasterView.get_row_parent()` to + return the batch to which the given row belongs. + """ + return row.batch def get_row_grid_data(self, obj): """ @@ -403,13 +425,73 @@ class BatchMasterView(MasterView): """ """ g = grid super().configure_row_grid(g) + batch = self.get_instance() + g.remove("batch", "status_text") + + # sequence g.set_label("sequence", "Seq.", column_only=True) + if "sequence" in g.columns: + i = g.columns.index("sequence") + if i > 0: + g.columns.remove("sequence") + g.columns.insert(0, "sequence") + # status_code g.set_renderer("status_code", self.render_row_status) + # tool button - create row + if not batch.executed and self.has_perm("create_row"): + button = self.make_button( + f"New {self.get_row_model_title()}", + primary=True, + icon_left="plus", + url=self.get_action_url("create_row", batch), + ) + g.add_tool(button, key="create_row") + def render_row_status( # pylint: disable=empty-docstring,unused-argument self, row, key, value ): """ """ return row.STATUS.get(value, value) + + def configure_row_form(self, form): + """ """ + f = form + super().configure_row_form(f) + + f.remove("batch", "status_text") + + # sequence + if self.creating: + f.remove("sequence") + else: + f.set_readonly("sequence") + + # status_code + if self.creating: + f.remove("status_code") + else: + f.set_readonly("status_code") + + # modified + if self.creating: + f.remove("modified") + else: + f.set_readonly("modified") + + def create_row_save_form(self, form): + """ + Override of the parent method + :meth:`~wuttaweb.views.master.MasterView.create_row_save_form()`; + this does basically the same thing except it also will call + :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.add_row()` + on the batch handler. + """ + session = self.Session() + batch = self.get_instance() + row = self.objectify(form) + self.batch_handler.add_row(batch, row) + session.flush() + return row diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 0ca4823..25f687f 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -360,8 +360,10 @@ class MasterView(View): # pylint: disable=too-many-public-methods .. attribute:: has_rows - Whether the model has "rows" which should also be displayed - when viewing model records. + Whether the model has "child rows" which should also be + displayed when viewing model records. For instance when + viewing a :term:`batch` you want to see both the batch header + as well as its row data. This the "master switch" for all row features; if this is turned on then many other things kick in. @@ -370,11 +372,37 @@ class MasterView(View): # pylint: disable=too-many-public-methods .. attribute:: row_model_class - Reference to a :term:`data model` class for the rows. + Reference to the :term:`data model` class for the child rows. - The base logic should not access this directly but instead call + Subclass should define this if :attr:`has_rows` is true. + + View logic should not access this directly but instead call :meth:`get_row_model_class()`. + .. attribute:: row_model_name + + Optional override for the view's row model name, + e.g. ``'WuttaWidget'``. + + Code should not access this directly but instead call + :meth:`get_row_model_name()`. + + .. attribute:: row_model_title + + Optional override for the view's "humanized" (singular) row + model title, e.g. ``"Wutta Widget"``. + + Code should not access this directly but instead call + :meth:`get_row_model_title()`. + + .. attribute:: row_model_title_plural + + Optional override for the view's "humanized" (plural) row model + title, e.g. ``"Wutta Widgets"``. + + Code should not access this directly but instead call + :meth:`get_row_model_title_plural()`. + .. attribute:: rows_title Display title for the rows grid. @@ -393,7 +421,8 @@ class MasterView(View): # pylint: disable=too-many-public-methods .. attribute:: rows_viewable Boolean indicating whether the row model supports "viewing" - - i.e. it should have a "View" action in the row grid. + i.e. the row grid should have a "View" action. Default value + is ``False``. (For now) If you enable this, you must also override :meth:`get_row_action_url_view()`. @@ -401,6 +430,18 @@ class MasterView(View): # pylint: disable=too-many-public-methods .. note:: This eventually will cause there to be a ``row_view`` route to be configured as well. + + .. attribute:: row_form_fields + + List of fields for the row model form. + + This is optional; see also :meth:`get_row_form_fields()`. + + .. attribute:: rows_creatable + + Boolean indicating whether the row model supports "creating" - + i.e. a route should be defined for :meth:`create_row()`. + Default value is ``False``. """ ############################## @@ -434,6 +475,7 @@ class MasterView(View): # pylint: disable=too-many-public-methods # row features has_rows = False + row_model_class = None rows_filterable = True rows_filter_defaults = None rows_sortable = True @@ -442,6 +484,7 @@ class MasterView(View): # pylint: disable=too-many-public-methods rows_paginated = True rows_paginate_on_backend = True rows_viewable = False + rows_creatable = False # current action listing = False @@ -2828,12 +2871,15 @@ class MasterView(View): # pylint: disable=too-many-public-methods When dealing with SQLAlchemy models it would return a proper mapped instance, creating it if necessary. + This is called by various other form-saving methods: + + * :meth:`edit_save_form()` + * :meth:`create_row_save_form()` + :param form: Reference to the *already validated* :class:`~wuttaweb.forms.base.Form` object. See the form's :attr:`~wuttaweb.forms.base.Form.validated` attribute for the data. - - See also :meth:`edit_save_form()` which calls this method. """ # use ColanderAlchemy magic if possible @@ -2939,7 +2985,16 @@ class MasterView(View): # pylint: disable=too-many-public-methods """ if hasattr(self, "rows_title"): return self.rows_title - return None + return self.get_row_model_title_plural() + + def get_row_parent(self, row): + """ + This must return the parent object for the given child row. + Only relevant if :attr:`has_rows` is true. + + Default logic is not implemented; subclass must override. + """ + raise NotImplementedError def make_row_model_grid(self, obj, **kwargs): """ @@ -3125,6 +3180,148 @@ class MasterView(View): # pylint: disable=too-many-public-methods """ raise NotImplementedError + def create_row(self): + """ + View to create a new "child row" record. + + This usually corresponds to a URL like ``/widgets/XXX/new-row``. + + By default, this view is included only if + :attr:`rows_creatable` is true. + + The default "create row" view logic will show a form with + field widgets, allowing user to submit new values which are + then persisted to the DB (assuming typical SQLAlchemy model). + + Subclass normally should not override this method, but rather + one of the related methods which are called (in)directly by + this one: + + * :meth:`make_row_model_form()` + * :meth:`configure_row_form()` + * :meth:`create_row_save_form()` + * :meth:`redirect_after_create_row()` + """ + self.creating = True + parent = self.get_instance() + parent_url = self.get_action_url("view", parent) + + form = self.make_row_model_form(cancel_url_fallback=parent_url) + if form.validate(): + row = self.create_row_save_form(form) + return self.redirect_after_create_row(row) + + index_link = tags.link_to(self.get_index_title(), self.get_index_url()) + parent_link = tags.link_to(self.get_instance_title(parent), parent_url) + index_title_rendered = HTML.literal(" »").join( + [index_link, parent_link] + ) + + context = { + "form": form, + "index_title_rendered": index_title_rendered, + "row_model_title": self.get_row_model_title(), + } + return self.render_to_response("create_row", context) + + def create_row_save_form(self, form): + """ + This method converts the validated form data to a row model + instance, and then saves the result to DB. It is called by + :meth:`create_row()`. + + :returns: The resulting row model instance, as produced by + :meth:`objectify()`. + """ + row = self.objectify(form) + session = self.Session() + session.add(row) + session.flush() + return row + + def redirect_after_create_row(self, row, **kwargs): + """ + Returns a redirect to the "view parent" page relative to the + given newly-created row. Subclass may override as needed. + + This is called by :meth:`create_row()`. + """ + parent = self.get_row_parent(row) + return self.redirect(self.get_action_url("view", parent)) + + def make_row_model_form(self, model_instance=None, **kwargs): + """ + Create and return a form for the row model. + + This is called by :meth:`create_row()`. + + See also related methods, which are called by this one: + + * :meth:`get_row_model_class()` + * :meth:`get_row_form_fields()` + * :meth:`~wuttaweb.views.base.View.make_form()` + * :meth:`configure_row_form()` + + :returns: :class:`~wuttaweb.forms.base.Form` instance + """ + if "model_class" not in kwargs: + model_class = self.get_row_model_class() + if model_class: + kwargs["model_class"] = model_class + + kwargs["model_instance"] = model_instance + + if not kwargs.get("fields"): + fields = self.get_row_form_fields() + if fields: + kwargs["fields"] = fields + + form = self.make_form(**kwargs) + self.configure_row_form(form) + return form + + def get_row_form_fields(self): + """ + Returns the initial list of field names for the row model + form. + + This is called by :meth:`make_row_model_form()`; in the + resulting :class:`~wuttaweb.forms.base.Form` instance, this + becomes :attr:`~wuttaweb.forms.base.Form.fields`. + + This method may return ``None``, in which case the form may + (try to) generate its own default list. + + Subclass may define :attr:`row_form_fields` for simple cases, + or can override this method if needed. + + Note that :meth:`configure_row_form()` may be used to further + modify the final field list, regardless of what this method + returns. So a common pattern is to declare all "supported" + fields by setting :attr:`row_form_fields` but then optionally + remove or replace some in :meth:`configure_row_form()`. + """ + if hasattr(self, "row_form_fields"): + return self.row_form_fields + return None + + def configure_row_form(self, form): + """ + Configure the row model form. + + This is called by :meth:`make_row_model_form()` - for multiple + CRUD views (create, view, edit, delete, possibly others). + + The ``form`` param will already be "complete" and ready to use + as-is, but this method can further modify it based on request + details etc. + + Subclass can override as needed, although be sure to invoke + this parent method via ``super()`` if so. + """ + form.remove("uuid") + self.set_row_labels(form) + ############################## # class methods ############################## @@ -3422,15 +3619,71 @@ class MasterView(View): # pylint: disable=too-many-public-methods @classmethod def get_row_model_class(cls): """ - Returns the **row** model class for the view, if defined. - Only relevant if :attr:`has_rows` is true. + Returns the "child row" model class for the view. Only + relevant if :attr:`has_rows` is true. - There is no default here, but a subclass may override by - assigning :attr:`row_model_class`. + Default logic returns the :attr:`row_model_class` reference. + + :returns: Mapped class, or ``None`` """ - if hasattr(cls, "row_model_class"): - return cls.row_model_class - return None + return cls.row_model_class + + @classmethod + def get_row_model_name(cls): + """ + Returns the row model name for the view. + + A model name should generally be in the format of a Python + class name, e.g. ``'BatchRow'``. (Note this is *singular*, + not plural.) + + The default logic will call :meth:`get_row_model_class()` and + return that class name as-is. Subclass may override by + assigning :attr:`row_model_name`. + """ + if hasattr(cls, "row_model_name"): + return cls.row_model_name + + return cls.get_row_model_class().__name__ + + @classmethod + def get_row_model_title(cls): + """ + Returns the "humanized" (singular) title for the row model. + + The model title will be displayed to the user, so should have + proper grammar and capitalization, e.g. ``"Batch Row"``. + (Note this is *singular*, not plural.) + + The default logic will call :meth:`get_row_model_name()` and + use the result as-is. Subclass may override by assigning + :attr:`row_model_title`. + + See also :meth:`get_row_model_title_plural()`. + """ + if hasattr(cls, "row_model_title"): + return cls.row_model_title + + return cls.get_row_model_name() + + @classmethod + def get_row_model_title_plural(cls): + """ + Returns the "humanized" (plural) title for the row model. + + The model title will be displayed to the user, so should have + proper grammar and capitalization, e.g. ``"Batch Rows"``. + (Note this is *plural*, not singular.) + + The default logic will call :meth:`get_row_model_title()` and + simply add a ``'s'`` to the end. Subclass may override by + assigning :attr:`row_model_title_plural`. + """ + if hasattr(cls, "row_model_title_plural"): + return cls.row_model_title_plural + + row_model_title = cls.get_row_model_title() + return f"{row_model_title}s" ############################## # configuration @@ -3660,3 +3913,24 @@ class MasterView(View): # pylint: disable=too-many-public-methods route_name=f"{route_prefix}.version", permission=f"{permission_prefix}.versions", ) + + ############################## + # row-specific routes + ############################## + + # create row + if cls.has_rows and cls.rows_creatable: + config.add_wutta_permission( + permission_prefix, + f"{permission_prefix}.create_row", + f'Create new "rows" for {model_title}', + ) + config.add_route( + f"{route_prefix}.create_row", f"{instance_url_prefix}/new-row" + ) + config.add_view( + cls, + attr="create_row", + route_name=f"{route_prefix}.create_row", + permission=f"{permission_prefix}.create_row", + ) diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py index 5bfda5a..92c0a4b 100644 --- a/tests/views/test_batch.py +++ b/tests/views/test_batch.py @@ -156,6 +156,17 @@ class TestBatchMasterView(WebTestCase): form = view.make_model_form(model_instance=batch) view.configure_form(form) + def test_is_editable(self): + handler = self.make_handler() + with patch.object( + mod.BatchMasterView, "get_batch_handler", return_value=handler + ): + view = self.make_view() + batch = handler.make_batch(self.session) + self.assertTrue(view.is_editable(batch)) + batch.executed = datetime.datetime.now() + self.assertFalse(view.is_editable(batch)) + def test_objectify(self): handler = self.make_handler() with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch): @@ -345,24 +356,33 @@ class TestBatchMasterView(WebTestCase): ): view = self.make_view() - self.assertRaises(AttributeError, view.get_row_model_class) + self.assertIsNone(view.get_row_model_class()) # row class determined from batch class - with patch.object( - mod.BatchMasterView, "model_class", new=MockBatch, create=True - ): + with patch.object(mod.BatchMasterView, "model_class", new=MockBatch): cls = view.get_row_model_class() self.assertIs(cls, MockBatchRow) - self.assertRaises(AttributeError, view.get_row_model_class) + self.assertIsNone(view.get_row_model_class()) # view may specify row class - with patch.object( - mod.BatchMasterView, "row_model_class", new=MockBatchRow, create=True - ): + with patch.object(mod.BatchMasterView, "row_model_class", new=MockBatchRow): cls = view.get_row_model_class() self.assertIs(cls, MockBatchRow) + def test_get_row_parent(self): + handler = self.make_handler() + with patch.object( + mod.BatchMasterView, "get_batch_handler", return_value=handler + ): + view = self.make_view() + batch = handler.make_batch(self.session) + self.session.add(batch) + row = handler.make_row() + handler.add_row(batch, row) + parent = view.get_row_parent(row) + self.assertIs(parent, batch) + def test_get_row_grid_data(self): handler = self.make_handler() model = self.app.model @@ -395,6 +415,9 @@ class TestBatchMasterView(WebTestCase): self.assertEqual(data.count(), 1) def test_configure_row_grid(self): + self.pyramid_config.add_route( + "mock_batches.create_row", "/batch/mock/{uuid}/new-row" + ) handler = self.make_handler() model = self.app.model @@ -410,18 +433,50 @@ class TestBatchMasterView(WebTestCase): with patch.object( mod.BatchMasterView, "get_batch_handler", return_value=handler ): + view = self.make_view() - Session = MagicMock(return_value=self.session) - Session.query.side_effect = lambda m: self.session.query(m) - with patch.multiple( - mod.BatchMasterView, create=True, Session=Session, model_class=MockBatch + with patch.object( + mod.BatchMasterView, "Session", return_value=self.session ): + with patch.multiple( + mod.BatchMasterView, + model_class=MockBatch, + route_prefix="mock_batches", + create=True, + ): + with patch.object( + self.request, "matchdict", new={"uuid": batch.uuid} + ): - with patch.object(self.request, "matchdict", new={"uuid": batch.uuid}): - view = self.make_view() - grid = view.make_row_model_grid(batch) - self.assertIn("sequence", grid.labels) - self.assertEqual(grid.labels["sequence"], "Seq.") + # basic sanity check + grid = view.make_row_model_grid(batch) + self.assertEqual( + grid.columns, ["sequence", "status_code", "modified"] + ) + self.assertIn("sequence", grid.labels) + self.assertEqual(grid.labels["sequence"], "Seq.") + self.assertEqual(grid.tools, {}) + + # missing 'sequence' column + grid = view.make_row_model_grid( + batch, columns=["status_code", "modified"] + ) + self.assertEqual(grid.columns, ["status_code", "modified"]) + + # sequence column is made to be first if present + grid = view.make_row_model_grid( + batch, columns=["status_code", "modified", "sequence"] + ) + self.assertEqual( + grid.columns, ["sequence", "status_code", "modified"] + ) + + # with "create row" button + with patch.object( + self.request, "is_root", new=True, create=True + ): + grid = view.make_row_model_grid(batch) + self.assertIn("create_row", grid.tools) def test_render_row_status(self): with patch.object(mod.BatchMasterView, "get_batch_handler", return_value=None): @@ -429,9 +484,53 @@ class TestBatchMasterView(WebTestCase): row = MagicMock(foo=1, STATUS={1: "bar"}) self.assertEqual(view.render_row_status(row, "foo", 1), "bar") + def test_configure_row_form(self): + handler = self.make_handler() + + with patch.object( + mod.BatchMasterView, "get_batch_handler", return_value=handler + ): + view = self.make_view() + + # some fields are readonly by default + form = view.make_form(model_class=MockBatchRow) + view.configure_row_form(form) + self.assertIn("sequence", form.fields) + self.assertTrue(form.is_readonly("sequence")) + self.assertIn("status_code", form.fields) + self.assertTrue(form.is_readonly("status_code")) + self.assertIn("modified", form.fields) + self.assertTrue(form.is_readonly("modified")) + + # but those fields are removed when creating + with patch.object(view, "creating", new=True): + form = view.make_form(model_class=MockBatchRow) + view.configure_row_form(form) + self.assertNotIn("sequence", form.fields) + self.assertNotIn("status_code", form.fields) + self.assertNotIn("modified", form.fields) + + def test_create_row_save_form(self): + handler = self.make_handler() + batch = MockBatch() + row = MockBatchRow() + + with patch.object( + mod.BatchMasterView, "get_batch_handler", return_value=handler + ): + with patch.object( + mod.BatchMasterView, "Session", return_value=self.session + ): + view = self.make_view() + form = view.make_form(model_class=MockBatchRow) + + with patch.object(view, "get_instance", return_value=batch): + with patch.object(view, "objectify", return_value=row): + with patch.object(handler, "add_row") as add_row: + view.create_row_save_form(form) + add_row.assert_called_once_with(batch, row) + def test_defaults(self): # nb. coverage only - with patch.object( - mod.BatchMasterView, "model_class", new=MockBatch, create=True - ): + with patch.object(mod.BatchMasterView, "model_class", new=MockBatch): mod.BatchMasterView.defaults(self.pyramid_config) diff --git a/tests/views/test_master.py b/tests/views/test_master.py index c02a431..0ed1eaa 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -36,6 +36,8 @@ class TestMasterView(WebTestCase): downloadable=True, executable=True, configurable=True, + has_rows=True, + rows_creatable=True, ): mod.MasterView.defaults(self.pyramid_config) @@ -327,6 +329,68 @@ class TestMasterView(WebTestCase): ): self.assertIs(mod.MasterView.get_row_model_class(), model.User) + def test_get_row_model_name(self): + + # error by default (since no model class) + self.assertRaises(AttributeError, mod.MasterView.get_row_model_name) + + # may specify model name directly + with patch.object(mod.MasterView, "row_model_name", new="Widget", create=True): + self.assertEqual(mod.MasterView.get_row_model_name(), "Widget") + + # or indirectly via model class + MyModel = MagicMock(__name__="Blaster") + with patch.object(mod.MasterView, "row_model_class", new=MyModel): + self.assertEqual(mod.MasterView.get_row_model_name(), "Blaster") + + def test_get_row_model_title(self): + + # error by default (since no model class) + self.assertRaises(AttributeError, mod.MasterView.get_row_model_title) + + # may specify model title directly + with patch.object( + mod.MasterView, "row_model_title", new="Wutta Widget", create=True + ): + self.assertEqual(mod.MasterView.get_row_model_title(), "Wutta Widget") + + # or may specify model name + with patch.object(mod.MasterView, "row_model_name", new="Blaster", create=True): + self.assertEqual(mod.MasterView.get_row_model_title(), "Blaster") + + # or may specify model class + MyModel = MagicMock(__name__="Dinosaur") + with patch.object(mod.MasterView, "row_model_class", new=MyModel): + self.assertEqual(mod.MasterView.get_row_model_title(), "Dinosaur") + + def test_get_row_model_title_plural(self): + + # error by default (since no model class) + self.assertRaises(AttributeError, mod.MasterView.get_row_model_title_plural) + + # subclass may specify *plural* model title + with patch.object( + mod.MasterView, "row_model_title_plural", new="People", create=True + ): + self.assertEqual(mod.MasterView.get_row_model_title_plural(), "People") + + # or it may specify *singular* model title + with patch.object( + mod.MasterView, "row_model_title", new="Wutta Widget", create=True + ): + self.assertEqual( + mod.MasterView.get_row_model_title_plural(), "Wutta Widgets" + ) + + # or it may specify model name + with patch.object(mod.MasterView, "row_model_name", new="Blaster", create=True): + self.assertEqual(mod.MasterView.get_row_model_title_plural(), "Blasters") + + # or it may specify model class + MyModel = MagicMock(__name__="Dinosaur") + with patch.object(mod.MasterView, "row_model_class", new=MyModel, create=True): + self.assertEqual(mod.MasterView.get_row_model_title_plural(), "Dinosaurs") + ############################## # support methods ############################## @@ -1722,6 +1786,34 @@ class TestMasterView(WebTestCase): # row methods ############################## + def test_get_rows_title(self): + model = self.app.model + + with patch.object(mod.MasterView, "row_model_class", new=model.User): + view = self.make_view() + + # default based on row model class + self.assertEqual(view.get_rows_title(), "Users") + + # explicit override + with patch.object(view, "rows_title", create=True, new="Mock Rows"): + self.assertEqual(view.get_rows_title(), "Mock Rows") + + def test_get_row_parent(self): + model = self.app.model + view = self.make_view() + + 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() + + with patch.multiple( + mod.MasterView, model_class=model.Person, row_model_class=model.User + ): + self.assertRaises(NotImplementedError, view.get_row_parent, user) + def test_collect_row_labels(self): # default labels @@ -1833,15 +1925,110 @@ class TestMasterView(WebTestCase): row = MagicMock() self.assertRaises(NotImplementedError, view.get_row_action_url_view, row, 0) - def test_get_rows_title(self): + def test_make_row_model_form(self): + model = self.app.model view = self.make_view() - # no default - self.assertIsNone(view.get_rows_title()) + # no model class + form = view.make_row_model_form() + self.assertIsNone(form.model_class) - # class may specify - with patch.object(view, "rows_title", create=True, new="Mock Rows"): - self.assertEqual(view.get_rows_title(), "Mock Rows") + # explicit model class + fields + form = view.make_row_model_form( + model_class=model.User, fields=["username", "active"] + ) + self.assertIs(form.model_class, model.User) + self.assertEqual(form.fields, ["username", "active"]) + + # implicit model + fields + with patch.multiple( + mod.MasterView, + create=True, + row_model_class=model.User, + row_form_fields=["username", "person"], + ): + form = view.make_row_model_form() + self.assertIs(form.model_class, model.User) + self.assertEqual(form.fields, ["username", "person"]) + + def test_configure_row_form(self): + model = self.app.model + view = self.make_view() + + # uuid field is pruned + with patch.object(mod.MasterView, "row_model_class", new=model.User): + form = view.make_form(model_class=model.User, fields=["uuid", "username"]) + self.assertIn("uuid", form.fields) + view.configure_row_form(form) + self.assertNotIn("uuid", form.fields) + + def test_create_row(self): + self.pyramid_config.add_route("home", "/") + self.pyramid_config.add_route("login", "/auth/login") + self.pyramid_config.add_route("people", "/people/") + self.pyramid_config.add_route("people.view", "/people/{uuid}") + model = self.app.model + + person = model.Person( + first_name="Fred", last_name="Flintstone", full_name="Fred Flintstone" + ) + self.session.add(person) + user = model.User(username="fred", person=person) + self.session.add(user) + self.session.commit() + + with patch.multiple( + mod.MasterView, + create=True, + model_class=model.Person, + row_model_class=model.User, + row_form_fields=["person_uuid", "username"], + route_prefix="people", + ): + with patch.object(mod.MasterView, "Session", return_value=self.session): + with patch.object(self.request, "matchdict", {"uuid": person.uuid}): + with patch.object( + mod.MasterView, "get_row_parent", return_value=person + ): + view = self.make_view() + + # get the form page + response = view.create_row() + self.assertIsInstance(response, Response) + self.assertEqual(response.status_code, 200) + # nb. no error + self.assertNotIn("Required", response.text) + + self.assertEqual(len(person.users), 1) + + # post request to add user + with patch.multiple( + self.request, + method="POST", + POST={ + "person_uuid": person.uuid.hex, + "username": "freddie2", + }, + ): + response = view.create_row() + # nb. should get redirect back to view page + self.assertEqual(response.status_code, 302) + # user should now be in DB + self.session.refresh(person) + self.assertEqual(len(person.users), 2) + + # try another post with invalid data (username is required) + with patch.multiple( + self.request, + method="POST", + POST={"person_uuid": person.uuid.hex, "username": ""}, + ): + response = view.create_row() + # nb. should get a form with errors + self.assertEqual(response.status_code, 200) + self.assertIn("Required", response.text) + self.session.refresh(person) + self.assertEqual(len(person.users), 2) class TestVersionedMasterView(VersionWebTestCase):