Add a custom “Charts” page

So far the framework has provided the bulk of features, and we’ve merely “employed” it to our ends. But now we will get a little more creative and bring in other tools, namely for the client-side JS.

When viewing a vehicle record, we want a link to another page which shows some charts to visualize its mileage data.

Defining the view callable

The view callable is a Pyramid term meaning essentially, what gets called when an incoming request matches a certain routing URL. In most cases (all, in this tutorial) it will be a method of a master view class.

Since we’re defining a whole new page with a separate URL, we will need a new method for the view callable. For now we’ll just make it a stub, can flesh it out later:

class VehicleView(MasterView):

   # ..<boilerplate elided>..

   # this will be invoked for URL like /vehicles/XXX/charts
   def charts(self):
       """
       Page to see a vehicle's charts.
       """
       vehicle = self.get_instance()
       # TODO: fetch chart data
       return {"vehicle": vehicle}

Note that a typical Pyramid view callable would return a context dict, which is used to render the template. That’s what we’ve shown here, and it works because we (will) configure the template when we register the view callable.

Many of the master view methods defined by the framework use a slightly different approach to allow for dynamic template lookup. That’s just FYI for now, we won’t go into it any further for the tutorial.

Defining the template

The first thing to figure out is where this template file should live and what it should be named.

The master class has a template_prefix which dictates where templates should live, and we’ll go with charts.mako for the name in this case.

So let’s create poser/web/templates/vehicles/charts.mako and give it stub contents:

<%inherit file="/base.mako" />

<%def name="page_content()">

  <h3 class="is-size-3">TODO: show some charts!</h3>

  <h4 class="is-size-4">
    but at least we have a vehicle:
    ${h.link_to(vehicle.name, url("vehicles.view", uuid=vehicle.uuid))}
  </h4>

</%def>

Note the class="is-size-3" and similar; these are Bulma style classes.

WuttaWeb uses Mako templates; for more background on how that works see Templates.

Configuring the route

Now we’ll add the Pyramid configuration so it knows to route requests for e.g. /vehicles/XXX/charts URL to our new method. We’ll also tell it which template to render.

Each master view class has a special defaults() method which is where this configuration happens. But it’s a class method so we can’t override it in the same way we might for an instance method.

This may not seem very pretty at first glance but it’s not so bad once you get used to it, and it allows for a lot of flexibility:

class VehicleView(MasterView):

   # ..<boilerplate elided>..

   @classmethod
   def defaults(cls, config):

       # first apply standard master view config from base class
       cls._defaults(config)

       # fetch config prefixes
       route_prefix = cls.get_route_prefix()
       permission_prefix = cls.get_permission_prefix()
       instance_url_prefix = cls.get_instance_url_prefix()
       template_prefix = cls.get_template_prefix()

       # configure charts route
       config.add_route(
           f"{route_prefix}.charts",
           f"{instance_url_prefix}/charts",
       )

       # configure charts view callable
       config.add_view(
           cls,
           attr="charts",
           route_name=f"{route_prefix}.charts",
           permission=f"{permission_prefix}.view",
           renderer=f"{template_prefix}/charts.mako",
       )

Note that we also added a permission restriction for the charts page. User can only see charts if they have the “view vehicle” permission.

Here are docs for some methods we used within defaults() there:

Improving the Charts page title/header

So far we don’t have anything in the page header, things look strangely bare. This was done intentionally just to show where the “lines” are in terms of what happens by default.

There is more than one way to customize the page title, but we’ll focus on doing it in the template here. We want to define 3 separate “title” values:

  • title() - is for the <title> element within the HTML <head>

  • content_title() - displayed in the colored background header

  • index_title() - displayed just above content title

Add these blocks to your charts.mako template:

## this is for the <title> within the <head>
<%def name="title()">Vehicles &raquo; ${vehicle.name} &raquo; Charts</%def>

## this is for the colored background header
<%def name="content_title()">Charts for ${vehicle.name}</%def>

## link back to view the vehicle, or list vehicles
<%def name="index_title()">
  ${h.link_to("Vehicles", url("vehicles"))}
  &raquo;
  ${h.link_to(vehicle.name, url("vehicles.view", uuid=vehicle.uuid))}
</%def>

Now the header should look more complete.

Fetching the chart data

Now let’s overhaul the charts() view callable so it loads the vehicle’s chart data.

We will be showing two charts: First a bar chart showing the gallons for each fillup, then a line chart showing miles per gallon as of each fillup.

The class method will collect data for both charts:

class VehicleView(MasterView):

   # ..<boilerplate elided>..

   def charts(self):
       """
       Page to see a vehicle's charts.
       """
       session = self.Session()
       vehicle = self.get_instance()

       # fetch all mileage entries for vehicle, oldest to newest
       entries = (
           session.query(MileageEntry)
           .filter(MileageEntry.vehicle == vehicle)
           .order_by(MileageEntry.date)
           .all()
       )

       # calculate miles per gallon for each fillup, and collect
       # data.  it will be rendered as JSON in the template.
       gallons_data = []
       mileage_data = []
       last_entry = None
       for entry in entries:

           # TODO: this logic is pretty rudimentary, only works if
           # all entry fields have values.  but proves the concept

           if entry.fillup_gallons:
               gallons_data.append(
                   {
                       "date": entry.date.isoformat(),
                       "gallons": float(entry.fillup_gallons),
                   }
               )

               if entry.odometer and last_entry and last_entry.odometer:
                   mileage_data.append(
                       {
                           "date": entry.date.isoformat(),
                           "mileage": float(
                               (entry.odometer - last_entry.odometer)
                               / entry.fillup_gallons
                           ),
                       }
                   )

           last_entry = entry

       return {
           "vehicle": vehicle,
           "gallons_data": gallons_data,
           "mileage_data": mileage_data,
       }

Displaying the charts

All necessary data should be available to the template now, so let’s overhaul the charts.mako file too.

First let’s replace the page_content() block with a new one:

<%def name="page_content()">

  <canvas ref="gallonsChart" width="400" height="60"></canvas>

  <br /><br />

  <canvas ref="mileageChart" width="400" height="60"></canvas>

</%def>

Then also add these blocks:

<%def name="extra_javascript()">
  ${parent.extra_javascript()}
  <script src="https://cdn.jsdelivr.net/npm/chart.js@^4"></script>
  <script src="https://cdn.jsdelivr.net/npm/luxon@^2"></script>
  <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@^1"></script>
</%def>

<%def name="modify_vue_vars()">
  ${parent.modify_vue_vars()}
  <script>

    ## data from view callable, as JSON
    ThisPageData.gallonsData = ${json.dumps(gallons_data)|n}
    ThisPageData.mileageData = ${json.dumps(mileage_data)|n}

    ## register hook to setup the charts on page load
    ThisPageData.mountedHooks.push(function() {

        new Chart(this.$refs.gallonsChart, {
            type: 'bar',
            data: {
                datasets: [{
                    label: "Gallons",
                    data: this.gallonsData,
                    backgroundColor: 'pink',
                }],
            },
            options: {
                plugins: {
                    title: {
                        text: "Gallons per Fillup",
                        display: true,
                    },
                },
                parsing: {
                    xAxisKey: 'date',
                    yAxisKey: 'gallons',
                },
                scales: {
                    x: {
                        type: 'time',
                        time: {unit: 'week'},
                    },
                }
            },
        })

        new Chart(this.$refs.mileageChart, {
            type: 'line',
            data: {
                datasets: [{
                    label: "Mileage as of fillup",
                    data: this.mileageData,
                    borderColor: 'blue',
                    tension: 0.1,
                }],
            },
            options: {
                plugins: {
                    title: {
                        text: "Miles per Gallon",
                        display: true,
                    },
                },
                parsing: {
                    xAxisKey: 'date',
                    yAxisKey: 'mileage',
                },
                scales: {
                    y: {
                        min: 0,
                    },
                    x: {
                        type: 'time',
                        time: {unit: 'week'},
                    },
                }
            },
        })
    })

  </script>
</%def>

Now viewing the Charts page you should really see some charts. Although you’ll have to add mileage entries before they show much.

Summary

This concludes the tutorial.

The charts page shows how to sort of “work around” the framework in a sense. It’s there for the fundamental stuff but tries to let you add whatever else you might need.

See WuttaWeb for further available documentation.