Progress Update on Semantic Templates for Pinax

As it turns out managing templates in an external app for dozens of different apps across 11 different projects is quite a tedious process. We need something simpler. After all, necessity is the mother of invention.

I wrote back in September that we were moving towards semantic markup for our shipped templates.

Photo by Nigel Tadyanehondo

In order to get there, I had been firing up a starter project instance, pip installing pinax-theme-bootstrap as a local editable and then working back and forth between two editors.

pinax start --dev account acct
cd acct
npm install
pip install -r requirements.txt
cd ../pinax-theme-bootstrap
pip install -e .
cd ../acct
code ../pinax-theme-bootstrap .  # open up Visual Studio Code with both

Then as I updated styles in the project instance, I was having to remember what to port back to the starter project. Sometimes I’d forget. Then days go by and I come back and need to work on some templates used in a different starter project so I’d start over.

Sometimes I’d lose the styles I built up.

Sometimes I’d discover we had templates not covered in a particular project.

Other times, I’d need to build up some fixture data, just to populate the templates.

At this rate, I was never going to finish.

Pinax Theme Tester

Wait. I’m an engineer. Repetitive tasks are the devil. Let’s fix that.

This time I started a starter project but when an end in mind. This was going to be able to host all templates we ship, sometimes in multiple different dimensions based on fixture data (empty state, normal state, lots of data state, etc).

This time to add a new set of templates for an app, I shouldn’t have to repeat myself. Maintenance of what I did should be low so that when we change the templates or app, it shouldn’t take much to update things. Porting back styles to the starter projects would be easy but we can do later and we won’t lose anything because we’ll just have a single project and it will be in source control so others can contribute or at least follow along.

Context Switcher

The way this works is you have a drop-down menu on the left that allows you to switch context by picking a configured context (most of these are apps but we needed to support templates that ship outside of a particular app context like pagination/pagination.html).

You just select the template set you want and then you’ll get a context menu on the right to pick the template or view:

After setting the context, the context menu on the right shows all the configured views:

Now when you select a view in the right menu, you are taking to a page that renders the template with mocked context from the theme:

Here is the login form from DUA. It’s instantiating the actual form from DUA and passing it to context.

Configurations

This was all built on the idea of configurations. All you have to do is add a module to the configs/ sub-package. Currently, we have:

pinax_theme_tester/
  configs/
    __init__.py       # where you register your config
    announcements.py
    base.py           # the ViewConfig that all your configs use
    blog.py
    cohorts.py
    documents.py
    dua.py
    general.py
    invitations.py
    likes.py
    messages.py
    notifications.py
    stripe.py

There is some cool code in here. Let’s dive in and see how it all works.

First off, let’s take a look at a config:

from django.conf.urls import url, include
from django.urls import reverse
from django.utils import timezone

from pinax.announcements.forms import AnnouncementForm

from .base import ViewConfig as BaseViewConfig


announcement = {
    "pk": 1,
    "title": "Bacon ipsum dolor amet corned beef beef tri-tip venison",
    "publish_start": timezone.now(),
    "publish_end": timezone.now(),
    "content": "Anim labore doner shank fatback ham enim burgdoggen ipsum pork chop deserunt.  Pancetta venison sausage officia sint.  Tri-tip hamburger pork chop dolor andouille.  Flank pork loin beef ribs, spare ribs bresaola dolore picanha tongue incididunt ham bacon."
}
announcement_list = [
    announcement,
    announcement,
    announcement,
    announcement
]

label = "announcement"
title = "Pinax Announcements"
url_namespace = "pinax_announcements"


class ViewConfig(BaseViewConfig):

    def resolved_path(self):
        return reverse("{}:{}".format(url_namespace, self.name), kwargs=self.pattern_kwargs)


views = [
    ViewConfig(
      pattern=r"^pinax-announcements/$",
      template="pinax/announcements/announcement_list.html",
      name="announcement_list",
      pattern_kwargs={},
      announcement_list=announcement_list
    ),
    # Rest of ViewConfig's cut for brevity
]
urlpatterns = [
    view.url()
    for view in views
]
url = url(r"", include(
  "pinax_theme_tester.configs.announcements", namespace=url_namespace)
)

Basically what we are doing here is configuring dynamic views and urls with fixture data. A ViewConfigis a simple object:

class ViewConfig(object):

    def __init__(self, name, pattern, template, pattern_kwargs, menu=True, **kwargs):
        self.name = name
        self.pattern = pattern
        self.template = template
        self.context = kwargs
        self.pattern_kwargs = pattern_kwargs
        self.menu = menu

    def make_view(self):
        return as_view(self.template, **self.context)

    def url(self):
        return url(self.pattern, self.make_view(), name=self.name)

    def resolved_path(self):
        return reverse(self.name, kwargs=self.pattern_kwargs)

It just provides some structure and a few common methods.

Some, well most, now, of our apps have namespaces so you can see how we override the resolved_path method in announcements.py to add in the namespace. We generate both urlpatterns as well as a url that will be included in our site urls. You can see that this URL basically includes its own module, picking up the urlpatterns we define in the module.

In the __init__.py we install a config by adding to the CONFIG_MAP:

from . import (
    dua,
    general,
    blog,
    announcements,
    cohorts,
    stripe,
    messages,
    likes,
    invitations,
    documents,
    notifications
)

CONFIG_MAP = {
    dua.label: dua,
    general.label: general,
    blog.label: blog,
    announcements.label: announcements,
    cohorts.label: cohorts,
    stripe.label: stripe,
    messages.label: messages,
    likes.label: likes,
    invitations.label: invitations,
    documents.label: documents,
    notifications.label: notifications
}

This is used in the urls.py and context_processors.py:

from django.conf.urls import url

from .configs import CONFIG_MAP
from .views import as_view, set_template_set


urlpatterns = [
    url(r"^$", as_view("homepage.html"), name="home"),
    url(r"^__set_tmpl/$", set_template_set, name="set_template_set")
]

for label in CONFIG_MAP:
    urlpatterns.append(CONFIG_MAP[label].url)

Pretty lean, right? We just spin through the CONFIG_MAP and add any url we find in the configs, which remember, is an include back to that config’s urlpatterns.

Then the context processor is just then putting the CONFIG_MAP into context as a list of available_configs so that we can populate the two menus dynamically. This eliminates any configuration or hard coding outside creating a config module and hooking it up in the config/__init__.py.

The configuration selector is just a form with a select input that has a bit of js to auto-submit the form on change which goes to a view and sets a session variable of what context you’ve selected.

{% for label, config in available_configs.items %}
<option value="{{ label }}"
        {% if current_config.label == config.label %}selected{% endif %}>
    {{ config.title }}
</option>
{% endfor %}

Then the context menu of views spins through those available_configs and creates menu items:

<div class="dropdown-menu">
    {% for v in current_config.views %}
        {% if v.menu %}
            <a class="dropdown-item" href="{{ v.resolved_path }}">
                {{ v.template }}
            </a>
        {% endif %}
    {% endfor %}
</div>

How I Use It

Now, I am just going through and adding configs for each app. It can take 15 minutes or up to an hour depending on the volume of views and states I need and a number of fixtures required. But once it’s setup, it should be long lasting until views changes in the app which at this point happens rarely.

Once I’ve finished adding the configs, I’ll go back through and focus on creating a SASS module for each app under static/src/scss/apps/*. I’ve created some issues to start publicly tracking what’s left.

Also, it is hosted online at http://templates.pinaxproject.com and gets automatically updated on every push to master.

PRs welcome!