============================ 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 :term:`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): # .... # 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 :attr:`~wuttaweb:wuttaweb.views.master.MasterView.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: .. code-block:: mako <%inherit file="/base.mako" /> <%def name="page_content()">

TODO: show some charts!

but at least we have a vehicle: ${h.link_to(vehicle.name, url("vehicles.view", uuid=vehicle.uuid))}

Note the ``class="is-size-3"`` and similar; these are `Bulma`_ style classes. .. _Bulma: https://bulma.io/ WuttaWeb uses `Mako `_ templates; for more background on how that works see :doc:`wuttaweb:narr/templates/index`. 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 :meth:`~wuttaweb:wuttaweb.views.master.MasterView.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): # .... @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: * :meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_route_prefix()` * :meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_permission_prefix()` * :meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_template_prefix()` * :meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_instance_url_prefix()` * :meth:`~pyramid:pyramid.config.Configurator.add_route()` * :meth:`~pyramid:pyramid.config.Configurator.add_view()` Adding a link from the "View" page ---------------------------------- At this point the charts page should actually be *working* (though not pretty). If you're viewing a vehicle record, and append ``/charts`` after the UUID in your browser address bar, you should see it. So we'll add a link from the view vehicle page, for easier access. In fact we'll use a button to make it "pop" a little more. The framework has a built-in utility for "cross-reference" links such as this. The original concept was for integrations where data is synced between systems, and it's handy for user to hop around and view data in each system. But the concept is generic enough you can use :meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_xref_buttons()` for any reason. And that's what we'll do here:: class VehicleView(MasterView): # .... # nb. these are only shown for the "view vehicle" page, and # since the charts page requires the "view vehicle" permission, # we know user is good, hence no perm check here def get_xref_buttons(self, vehicle): return [ self.make_button( "View Charts", url=self.get_action_url("charts", vehicle), primary=True, icon_left="chart-line", ), ] Now when you view a vehicle record you should see the button to view charts. The framework is getting its icons from `FontAwesome`_. .. _FontAwesome: https://fontawesome.com/search?ic=free-collection 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 ```` 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: .. code-block:: mako ## this is for the <title> within the <head> <%def name="title()">Vehicles » ${vehicle.name} » 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"))} » ${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: .. code-block:: mako <%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: .. code-block:: mako <%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 :doc:`wuttaweb:index` for further available documentation.