Customize the Master Views

This quickly becomes a sprawling topic; there are a myriad of ways one might customize a master view class.

Unfortunately there is no “wizardry” to guide you through any of this, you are armed only with the documentation and source code.

For this tutorial we will limit to just a few relatively simple changes. It’s a simple app after all, not much else needs to be spiced up if you’re just trying to capture/track some data.

Fixing a foreign key reference form field

Not sure what else to call this “customization” but currently it is part of the game.

In our example it’s the Vehicle reference field when we need to create or edit a Mileage Entry record. The default form logic is not yet smart enough to handle such fields well. Indeed the behavior is effectively broken; hence we must “fix” it.

And really the fix here gives us a way to introduce more customization points, so there’s some silver lining. The first thing we must do is create a new “schema node type” class representing the “object reference” in question. In our case we need to reference a vehicle, so our node type will be based around that.

Create/edit the module at poser/web/forms/schema.py to define the type class (you may need to make the forms dir and create empty __init__.py for it as well):

from wuttaweb.forms.schema import ObjectRef
from poser.db.model import Vehicle

class VehicleRef(ObjectRef):
    model_class = Vehicle

    def sort_query(self, query):
        # nb. could also do: query.order_by(Vehicle.make, Vehicle.model)
        return query.order_by(Vehicle.name)

    def get_object_url(self, vehicle):
        return self.request.route_url("vehicles.view", uuid=vehicle.uuid)

As you can see this will control the ordering of vehicle records, e.g. for use in a dropdown, and also specifies how to generate a URL to view a given vehicle. See ObjectRef for more about the base schema type.

With that in place we can go back to edit the MileageEntryView class, and tell it to use the custom type via set_node(). This time we must configure the form instead of the grid:

from poser.web.forms.schema import VehicleRef

class MileageEntryView(MasterView):

   # ..<boilerplate elided>..

   def configure_form(self, form):
       super().configure_form(form)

       # use custom node type for vehicle reference
       form.set_node("vehicle", VehicleRef(self.request))

Now when you create or edit a mileage entry, things should look much better!

As a sanity check you can try to submit an empty form and it should not actually create the mileage entry, but give you some warnings instead. You should also be able to create a new record okay as long as you give it the required values.

Setting default column sort for a grid

Now if you add very many mileage entry records you will at some point notice that the grid is sortable, and then you may notice that it is not sorted by default. (For instance, after you first login to the app.)

That’s an easy fix via sort_defaults, e.g. like this:

class MileageEntryView(MasterView):

   sort_defaults = ("date", "desc")

It’s generally a good idea to set default sort for all master views, so here’s the other one:

class VehicleView(MasterView):

   sort_defaults = "name"

Note that you only have to specify column name unless you want a descending sort, as in the first example.

Also note that while in fact the default template theme supports multi-column sorting there is one caveat, you can’t specify a multi-column sort as the default. Single column only for the default sort.

Setting custom grid cell rendering

Quite often you may need to tweak how grid cell values are rendered. This is handled on a per-column basis of course.

Here we’ll do a couple of pretty basic things for the mileage entry grid, declaring one column should be rendered as “currency” (dollars) and another as a “quantity” (which adds thousand-separator commas):

class MileageEntryView(MasterView):

    def configure_grid(self, grid):
        super().configure_grid(grid)

        # ..<previous link boilerplate elided>..

        # show payment with dollar sign and 2 decimals
        grid.set_renderer("fillup_payment", "currency")

        # add thousands separator for odometer
        grid.set_renderer("odometer", "quantity")

The set_renderer() method has a few built-in renderers such as those shown here, but you can also give it a callable which lets you render any way you like.

Note that how a grid “renders” a column’s values does not change their sorting behavior, since that is handled on the server side within the SQL database. (At least for typical scenarios.)

Unfortunately the grid rendering logic is only used by the grid, and will not affect the mileage entry form at all. So we make similar changes to it next.

Setting custom form field types

While we can just give the grid a “renderer callable” and thereby make column values appear however we like, it’s not that simple for form fields.

Each field is represented by multiple objects under the hood, which handle the data validation but also the readonly display. The latter can be customized in more than one way, but ultimately the field “widget” will do the rendering. (See also Forms.)

So one option is to set the widget explicitly, but you can also set the “type” for the field, which in turn will usually determine the widget.

We actually showed an example of this earlier, for the Vendor reference field using a custom node schema type.

This time around we’ll use set_node() for the payment and odometer fields, for an effect similar to what we did for the grid above:

from wuttaweb.forms.schema import WuttaMoney, WuttaQuantity

class MileageEntryView(MasterView):

   def configure_form(self, form):
       super().configure_form(form)

       # ..<previous vehicle field boilerplate elided>..

       # use "money" type for better rendering
       form.set_node("fillup_payment", WuttaMoney(self.request))

       # use "quantity" type for better rendering
       form.set_node("odometer", WuttaQuantity(self.request))

But while we’re here there’s another related tweak we should make. There is a special notes widget we can use for such fields. This will use a <textarea> element for editing, and also improves the readonly field rendering:

class MileageEntryView(MasterView):

   def configure_form(self, form):
       super().configure_form(form)

       # ..<previous boilerplate elided>..

       # use special 'notes' widget for 'notes' field
       form.set_widget("notes", "notes")

As with the grid set_renderer() call, this set_widget() has “some” (currently just the one for notes) built-in widgets but otherwise it expects you to pass a full-on widget instance.

Now when viewing a single mileage entry record, the payment and odometer values should have better formatting. And when editing a record, the notes field should have a textarea.

Adding “rows” grid when viewing a record

You’ll notice when you view a mileage entry record, the Vehicle field has a hyperlink to view that record. This is courtesy of the “object reference node type” we setup earlier.

But when you view a vehicle record, the page is awfully sparse, with no indication of what mileage data we have collected for it.

The desired behavior will vary by use case, but here we’ll configure the vehicle view such that all related mileage entries are shown in a grid below the vehicle’s main form/fields.

The framework generally refers to this as a “rows grid” (as opposed to the “main grid”) and the MasterView base class has various features built-in for this pattern. It’s not all magic though, we have to specify some details.

Many of the row grid details will be quite similar to the main grid configuration we already did for the MileageEntry view, so we sort of repeat ourselves a little here in that sense:

from poser.db.model import Vehicle, MileageEntry

class VehicleView(MasterView):
   model_class = Vehicle

   # ..<other boilerplate elided>..

   has_rows = True
   row_model_class = MileageEntry

   # nb. omit vehicle column since that's the parent record
   row_grid_columns = [
       "date",
       "fillup_gallons",
       "fillup_payment",
       "odometer",
   ]

   # we again want reverse sort by date
   rows_sort_defaults = ("date", "desc")

   # must declare how to get the row grid data for a parent record
   def get_row_grid_data(self, vehicle):
       session = self.Session()
       # nb. we do *not* sort this query, since user can click
       # column headers to control sorting separately.  (in certain
       # other scenarios e.g. where data is not a query, you may
       # want to pre-sort the row data.)
       return session.query(MileageEntry).filter(MileageEntry.vehicle == vehicle)

   # also must declare how to get the parent record for a given row
   def get_row_parent(self, mileage_entry):
       return mileage_entry.vehicle

   # configuring a row grid works the same, just a different method name
   def configure_row_grid(self, grid):
       super().configure_row_grid(grid)

       # add view hyperlink for date column
       grid.set_link("date")

       # show payment with dollar sign and 2 decimals
       grid.set_renderer("fillup_payment", "currency")

       # add thousands separator for odometer
       grid.set_renderer("odometer", "quantity")

       # add 'view' action, if user is allowed access
       if self.request.has_perm("mileage_entries.view"):
           grid.add_action("view", icon="eye", url=self.get_row_action_url_view)

   # view URL generator for a given row
   def get_row_action_url_view(self, mileage_entry, i):
       return self.request.route_url("mileage_entries.view", uuid=mileage_entry.uuid)

Note that some of the special class attributes above are prefixed with row_ but some are prefixed with rows_ - unless/until those are aligned, you just have to watch out for that.

FYI, it’s possible to setup CRUD for these “row records” as well, within the vehicle master view. But in this case we already have full CRUD for the mileage entries, therefore we setup the view URL logic to point to the mileage entries master view.

With the above in place, viewing a vehicle record should become more interesting. The row grid works the same as the main grid, in terms of sorting, filtering (etc.) from user’s perspective.

You should now have easy navigation from a vehicle to a particular mileage entry, and vice versa.

Here are docs for all the row-related things we just touched:

Summary

Just to wrap things up, we’ve touched on several common customization needs for the master views. But of course this barely scratches the surface. (We didn’t even touch any templates or JS etc.)

Unfortunately this area needs a lot more documentation than currently exists. For now the WuttaWeb package API docs may be the most helpful. But regardless WuttaWeb is where you’ll find everything.

However this tutorial is not over just yet, so keep reading.