En kodare


Anders Hovmöller
GitHub twitter email

Introducing iommi, part 2

2020-05-14

Continuing from part 1:

The code on github

Pages

So far we’ve just created a table, but often you want something a little more complex, especially for your index page. iommi has a concept of a Page that is used to build up a bigger page from smaller building blocks. Let’s build out our simple web app to have separate pages for albums, artists and tracks:

path('albums/', Table(auto__model=Album).as_view()),
path('artists/', Table(auto__model=Artist).as_view()),
path('tracks/', Table(auto__model=Track).as_view()),

and an index page:

class IndexPage(Page):
    title = html.h1('Supernaut')
    welcome_text = 'This is a discography of the best acts in music!'

    artists = Table(auto__model=Artist, page_size=5)
    albums = Table(auto__model=Album, page_size=5)
    tracks = Table(auto__model=Track, page_size=5)

with a url definition like this:

path('', IndexPage().as_view()),

html is a little fragment builder to make it easier and faster to build small html parts. html.div('foo') is just a more convenient way to write Fragment(tag='div', children__text='foo'). Fragments are used internally throughout iommi, because they allow you to define a small bit of html that can be customized later. Let’s look at an example:

>>> class MyPage(Page):
...    title = html.h1('Supernaut')

>>> MyPage().bind().__html__()
'<h1>Supernaut</h1>'

>>> MyPage(parts__title__attrs__class__foo=True).bind().__html__()
'<h1 class="foo">Supernaut</h1>'

This is used throughout iommi to provide good defaults that can be customized easily when needed.

A Page can contain any Part (like Fragment, Table, Form, Menu, etc), or plain strings. Escaping is handled like you’d expect from Django where strings are escaped, and you can use mark_safe to send your raw html straight through.

For our demo we’ll also introduce a page for an artist:

def artist_page(request, artist):
    artist = get_object_or_404(Artist, name=artist)

    class ArtistPage(Page):
        title = html.h1(artist.name)

        albums = Table(auto__rows=Album.objects.filter(artist=artist))
        tracks = Table(auto__rows=Track.objects.filter(album__artist=artist))

    return ArtistPage()

urlpatterns = [
    # [...snip...]
    path('artist/<artist>/', artist_page),
]

Note here how we specify auto__rows to supply a QuerySet instead of a model. This is very convenient in many cases, and is otherwise the same as specifying auto__model and rows.

cell__format

In iommi you can customize the rendering on many different levels, depending on what the situation requires. The last layer of customization is format which is used to convert the value of a cell to a string that is inserted into the html (or CSV or whatever output format you are targeting):

class IndexPage(Page):
    # [...snip...]
    albums = Table(
        # [...snip...]
        columns__artist__cell__format=lambda value, **_: 
            format_html('<a href="/artist/{}/">{}</a>', value, value)
    )
    # [...snip...]

columns__artist__cell__format should be read as something similar to columns.artist.cell.format. This way of jumping namespace with __ instead of . (because . is syntactically invalid!) is something Django started doing for query sets and we really like it so we’ve taken this concept further and it is now everywhere in iommi.

The other levels of customization are value which is how the value is extracted from the row, attr which is the attribute that is read (if you don’t customize value), and lastly template which you use to override the entire rendering of the cell (including the td tag!).

You can also override template on the row to customize the row rendering. Again this includes the tr tag.

cell__url

A very common case of tables is to show a link in the cell. You can do that with cell_format and cell__template like above, but it’s such a common case that we supply a special convenience method cell__url for this. Let’s make the artist column link to the artist page in our table. First we add a get_absolute_url on the model, then replace the columns__artist__cell__format we had above with:

class IndexPage(Page):
    # [...snip...]
    albums = Table(
        # [...snip...]
        columns__artist__cell__url=lambda value, **_: value.get_absolute_url(),
    )
    # [...snip...]

Much better!

But actually, this is such a common case that we do this by default for you for ForeignKey columns if the target model has get_absolute_url. So we can just remove the columns__artist__cell__url specification entirely. But we do want the name column to link to the album page so the total definition becomes:

    albums = Table(
        auto__model=Album,
        page_size=5,
        columns__name__cell__url=lambda row, **_: row.get_absolute_url(),
    )

Table filters

It’s very often we want the ability to filter lists, which is why iommi also provides this. To enable a filter make sure include is True for the filter of a column. We enable filtering for name, year, and artist:

    albums = Table(
        # [...snip...]
        columns__name__filter__include=True,
        columns__year__filter__include=True,
        columns__year__filter__field__include=False,
        columns__artist__filter__include=True,
    )

columns__year__filter__field__include=False means we turn off the Field in the form that is created, but we can still search for the year in the advanced search language.

To handle selecting from a choice field that is backed by a QuerySet that can contain thousands or millions of rows, iommi by default uses a select2 filter control with an automatic ajax endpoint. You can read more about this in the full documentation. The automatic endpoint is handled by iommi on the same url as the view. An advantage to this approach is that we only need to be sure our view has the correct permission checks and then we also know the select box (or ajax endpoint) has the same checks. This makes it easy to reason about the security of the product.

The advanced filter means users can write queries like year>1960 and title:war to find albums published after 1960 and that contain the word “war”.

Forms

iommi also comes with a library for forms. This can look very much like the forms library built into Django, but is different in some crucial ways. Let’s look at the most basic example from the Django documentation:

from django import forms

class NameForm(forms.Form):
    your_name = forms.CharField(label='Your name')
<form action="/your-name/" method="post">
    {% csrf_token %}
    {{ form }}
    <input type="submit" value="Submit">
</form>

In iommi the same is written as:

from iommi import Form, Field

class NameForm(Form):
    your_name = Field.text()

And in the template:


{{ form }}

In iommi you always get a form encoding specified on the form, so they all work with file uploads. Missing form encoding on the form tag is a very common stumbling block for beginners. You also get a submit action by default which you can configure via actions__submit:

from iommi import Form, Field

class NameForm(Form):
    your_name = Field.text()

    class Meta:
        actions__submit__display_name = 'Save'

In iommi we use class Meta a lot, similar to Django, but in iommi it’s not just a bucket of values that someone might or might not read, it has a precise definition: values in Meta are passed into the constructor. So the example above is roughly the same as:

NameForm(actions__submit__display_name='Save')

Worth pointing out is that values of Meta are defaults, so you can still override at the constructor call.

An advantage to this strict definition is that we don’t have silent failures in iommi. If you make a spelling mistake in a value in Meta, you will get an error message.

Automatic forms

iommi can also derive forms from Django model definitions: Form(auto__model=Artist). You can specify which fields to include or exclude via auto__include or auto__exclude, and the fields can still be customized fully. An example of this could be to insert a CSS class foo on the label tag of a field name:

form = Form(
    auto__model=Artist,
    fields__name__label__attrs__class__foo=True,
)

There are many many more customizations options available, you can find more in the HOWTO and the docs for Field.

Automatic views

iommi goes a step further than this though, by supplying full views that can be used from either a declarative form or an auto generated form. An example is to have a create view for an Artist:

urlpatterns = [
    path('create/', Form.create(auto__model=Artist).as_view()),
] 

There are three built in forms/views like this: create, edit, and delete. The delete view is a read only form with some styling for the submit button and a submit handler that delete the object. We find this to be really nice as a confirmation page because you can see what you are about to delete.

Actions

An Action in iommi is a link or a button. We use them for submit buttons of forms and for links that you can add to a part. A common use case is to add links to a table. In our example app we want to add a create button for staff:

class AdminTable(Table):    
    # [...snip...]
    class Meta:
        # [...snip...]
        actions__create_album = Action(
            attrs__href='/albums/create/', 
            include=lambda request, **_: request.user.is_staff,
        )

cell__template

Now that we have a basic app, we’d like to customize the look of the index page a bit. A plain html table with text doesn’t look very cool, so we will spice it up with album covers. This is what it looks right now:

We’ll start by removing the artists section.

A custom cell template might be a good start to make it look nicer. The demo app ships with images, we just need to link to them:

albums = Table(
    auto__model=Album,
    columns__album_art=Column(
        attr=None,
        cell__template=Template('''
            <td>
                <img 
                    height="30"
                    src="/static/album_art/{{ row.artist }}/{{ row.name }}.jpg">
            </td>
        '''),
    ),
)

That’s a start, but we want something a bit more showy, let’s get rid of html tables entirely:

row__template

To override the rendering of an entire row we use cell__template. We also change the table from rendering a table tag to a div, and turn off the table header:

albums = AlbumTable(
    tag='div',
    header__template=None,
    row__template=Template("""
        <div class="card" style="width: 15rem; display: inline-block;" {{ cells.attrs }}>
            <img class="card-img-top" src="/static/album_art/{{ row.artist }}/{{ row.name|urlencode }}.jpg">
            <div class="card-body text-center">
                <h5>{{ cells.name }}</h5>
                <p class="card-text">
                    {{ cells.artist }}
                </p>
            </div>
        </div>
    """),
)

(You can specify the name of a template file here instead of writing the template inline like this. This way is nicer for small things and quick demos.)

Now we’re almost at the full app screenshot at the beginning of part 1, we just have the menu at the top left.

iommi also has a menu system. It follows the same patterns we’ve seen so far. For this example app we’ll do something simple:

class SupernautMenu(Menu):
    home = MenuItem(url='/')
    artists = MenuItem()
    albums = MenuItem()
    tracks = MenuItem()

By default the url becomes the name, so the artists menu item becomes /artists/.

Adding it to the index page:

class IndexPage(Page):
    menu = SupernautMenu()

    # [...snip...]

and we’ve arrived at the screenshot I promised at the beginning of part 1:

Just one more thing…

Admin

With these high level abstractions we’ve seen so far (pages, tables, queries, forms, fragments) we can easily build more powerful components. Which is what we’ve done with the administration interface built into iommi. Installing it is as simple as:

class MyAdmin(Admin):
    class Meta:
        pass


urlpatterns = [
    path('iommi-admin/', include(MyAdmin.urls())),
]

Customization is easy with IOMMI_DEBUG on (default on if DEBUG is on), here’s how to use the pick tool:

You can override an entire field rendering with template, the template of the label with label__template, the name of a field with display_name, add a CSS class to the label tag with label__attrs__class__foo=True, and much more. Customization is at all levels, and in all these cases you can supply a callable for even more flexibility.

More about the admin in the iommi docs.

Wrap up

I’m glad you read this far! This has been a very shallow introduction, but I’ve touched on all the major parts in some way, and there is a lot of material to cover. I hope you want to give iommi a try.

The full documentation is at iommi.rocks, and we are on github at TriOptima/iommi. This example project is at boxed/Supernaut.

« Introducing iommi Python is slow - it doesn't have to be »