3
0
Fork 0

fix: add basic create_row() support, esp. for batch views

This commit is contained in:
Lance Edgar 2025-12-04 22:57:12 -06:00
parent 66eccc52a2
commit 10610d5809
6 changed files with 696 additions and 48 deletions

View file

@ -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):

View file

@ -0,0 +1,4 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/form.mako" />
<%def name="title()">New ${row_model_title}</%def>

View file

@ -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

View file

@ -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("<span>&nbsp;&raquo;</span>").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",
)

View file

@ -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)

View file

@ -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):