24
loading...
This website collects cookies to deliver better user experience
title
, byline
, preamble
, a reports
section which breaks up the news snippets into class of launch (small, medium and large). At the end of the report there, is a small timeline
of upcoming launches. The part we want to focus on for this tutorial is the reports
section, and Wagtail's snippets are a perfect way to store this kind of related content in a centralised way.rocket_report
.django-admin startapp rocket_report
settings.py
INSTALLED_APPS = [
# ...
'rocket_report',
# ... wagtail & django items
# ensure that snippets and modeladmin apps are added
'wagtail.snippets',
'wagtail.contrib.modeladmin',
]
rocket_report
with models, views, etc.RocketReportPage
page model.models.py
file, code example below../manage.py makemigrations
& ./manage.py migrate
from django.db import models
from modelcluster.fields import ParentalKey
from wagtail.core.models import Page, Orderable
from wagtail.core.fields import RichTextField
from wagtail.admin.edit_handlers import FieldPanel, InlinePanel
from wagtail.images.edit_handlers import ImageChooserPanel
class RocketReportPage(Page):
# Database fields
byline = models.CharField(blank=True, max_length=120)
preamble = RichTextField(blank=True)
main_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
# Editor panels configuration
content_panels = Page.content_panels + [
FieldPanel("byline"),
FieldPanel("preamble", classname="full"),
ImageChooserPanel("main_image"),
# TBC - reports
InlinePanel("related_launches", label="Timeline"),
]
class Launch(Orderable):
page = ParentalKey(
RocketReportPage, on_delete=models.CASCADE, related_name="related_launches"
)
date = models.DateField("Launch date")
details = models.CharField(max_length=255)
panels = [
FieldPanel("date"),
FieldPanel("details"),
]
models.py
file, code example below../manage.py makemigrations
& ./manage.py migrate
.INSTALLED_APPS
contains 'wagtail.snippets'
from django.db import models
# ... include existing imports from model.py
from wagtail.snippets.models import register_snippet
from wagtail.snippets.edit_handlers import SnippetChooserPanel
from wagtail.admin.edit_handlers import FieldPanel
class RocketReportPage(Page):
# ...
content_panels = Page.content_panels + [
# ... other field panels
ImageChooserPanel("main_image"),
# ... other field panels
]
@register_snippet
class RocketReport(models.Model):
STATUS_CHOICES = [
("SUBMITTED", "Submitted"),
("REVIEWED", "Reviewed"),
("PROPOSED", "Proposed"),
("HOLD", "Hold"),
("CURRENT", "Current"),
]
CATEGORY_CHOICES = [
("BLANK", "Uncategorised"),
("SMALL", "Small"),
("MEDIUM", "Medium"),
("LARGE", "Large"),
]
submitted_url = models.URLField(null=True, blank=True)
submitted_by = models.CharField(max_length=255, blank=True)
status = models.CharField(max_length=255, blank=True, choices=STATUS_CHOICES)
title = models.CharField(max_length=255)
content = RichTextField(blank=True)
category = models.CharField(
max_length=255, choices=CATEGORY_CHOICES, default="BLANK"
)
panels = [
FieldPanel("title"),
FieldPanel("status"),
FieldPanel("category"),
FieldPanel("content"),
FieldPanel("submitted_url"),
FieldPanel("submitted_by"),
]
def __str__(self):
return self.title
class RocketReportPageReportPlacement(Orderable, models.Model):
page = ParentalKey(
RocketReportPage, on_delete=models.CASCADE, related_name="rocket_reports"
)
rocket_report = models.ForeignKey(
RocketReport, on_delete=models.CASCADE, related_name="+"
)
panels = [
SnippetChooserPanel("rocket_report"),
]
def __str__(self):
return self.page.title + " -> " + self.rocket_report.title
ModelAdmin
. Note that this is Wagtail's ModelAdmin not Django's.wagtail.contrib.modeladmin
to your INSTALLED_APPS
in your settings.py
ModelAdmin
class in admin.py, code example below.wagtail_hooks.py
, code example below.# admin.py
from wagtail.contrib.modeladmin.options import ModelAdmin
from .models import RocketReport
class RocketReportAdmin(ModelAdmin):
model = RocketReport
menu_icon = "fa-rocket"
list_display = ("title", "status", "category", "submitted_by")
list_filter = ("status", "category")
search_fields = ("title", "status", "category", "submitted_by")
# wagtail_hooks.py
from wagtail.contrib.modeladmin.options import modeladmin_register
from .admin import RocketReportAdmin
modeladmin_register(RocketReportAdmin)
KanbanMixin
that will house the customisations to our ModelAdmin
. We could put all of these customisations directly on our RocketReportAdmin
but we want to set up something reusable. It would be good to have a basic understanding of how to customise the index view (listing) before reading on./templates/modeladmin/kanban_index.html
{% extends "modeladmin/index.html" %}
at the topextra_css
, extra_js
and content_main
.{{ block.super }}
to the js & css blocks so that existing scripts and styles will be used.content_main
block - add a div that will contain the Kanban with class kanban-wrapper listing
and an inner div with an id kanban-mount
which is used by JKanban to add the rendered kanban boardextra_css
block - Add the link
tag from jsdelivr and some basic styles within a <style>
tag, in the code below we are starting with some margins and handling of longer boardsextra_js
block - our goal is to simply load up some dummy data based on the options docs for jKanban
document.addEventListener("DOMContentLoaded", function () {
var options = {
boards: [
{
id: "column-0",
title: "Column A",
item: [
{ id: "item-1", title: "Item 1" },
{ id: "item-2", title: "Item 2" },
{ id: "item-1", title: "Item 3" },
],
},
{
id: "column-1",
title: "Column B",
item: [
{ id: "item-4", title: "Item 4" },
{ id: "item-5", title: "Item 4" },
{ id: "item-5", title: "Item 6" },
],
},
],
};
// build the kanban board with supplied options
var kanban = new jKanban(
Object.assign({}, options, { element: "#kanban-mount" })
);
});
{% extends "modeladmin/index.html" %}
{% comment %} templates/modeladmin/kanban_index.html {% endcomment %}
{% block extra_css %}
{{ block.super }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jkanban@1/dist/jkanban.min.css">
<style>
.kanban-wrapper {
width: 100%;
overflow-x: auto; /* add horizontal scrolling for wide boards */
margin-top: 1rem;
margin-bottom: 1rem;
}
.kanban-item {
min-height: 4rem;
}
</style>
{% endblock %}
{% block extra_js %}
{{ block.super }}
<script src="https://cdn.jsdelivr.net/npm/jkanban@1/dist/jkanban.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var options = {
boards: [
{
id: 'column-0',
title: 'Column A',
item: [{ id: 'item-1', title: 'Item 1'}, { id: 'item-2', title: 'Item 2'}, { id: 'item-1', title: 'Item 3'}]}
,
{
id: 'column-1',
title: 'Column B',
item: [{ id: 'item-4', title: 'Item 4'}, { id: 'item-5', title: 'Item 4'}, { id: 'item-5', title: 'Item 6'}]
}
]
};
// build the kanban board with supplied options
// see: https://github.com/riktar/jkanban#var-kanban--new-jkanbanoptions
var kanban = new jKanban(Object.assign({}, options, {element: '#kanban-mount'}));
});
</script>
{% endblock %}
{% block content_main %}
<div class="kanban-wrapper listing">
<div id="kanban-mount"></div>
</div>
{% endblock %}
ModelAdmin
to use it when rendering the index listing view instead of the default. We can leverage a mixin approach to override the ModelAdmin
methods while still honouring the existing config on a per app or model basis.admin.py
file.ModelAdmin
uses a method get_index_template
to get the index listing template, simply override this to call the defined index_template_name
or get_templates("kanban_index")
.templates/modeladmin/kanban_index.html
RocketReportAdmin
class, before the ModelAdmin
usage.
# rocket_report/admin.py
class KanbanMixin:
def get_index_template(self):
# leverage the get_template to allow individual override on a per model basis
return self.index_template_name or self.get_templates("kanban_index")
class RocketReportAdmin(KanbanMixin, ModelAdmin):
model = RocketReport
# ...
json_script
which provides a way for sever generated content to be provided to JS in a view in a safe way.views.py
called KanbanView
wagtail.contrib.modeladmin.views.IndexView
get_context_data
, calling super and then adding kanban_options
with similar dummy data that we used in the templateKanbanView
within the KanbanMixin
kanban_index.html
to inject the JSON data via json-script
# views.py
from wagtail.contrib.modeladmin.views import IndexView
class KanbanView(IndexView):
def get_kanban_data(self, context):
return [
{
"id": "column-id-%s" % index,
"item": [
{"id": "item-id-%s" % obj["pk"], "title": obj["title"],}
for index, obj in enumerate(
[
{"pk": index + 1, "title": "%s Item 1" % column},
{"pk": index + 2, "title": "%s Item 2" % column},
{"pk": index + 3, "title": "%s Item 3" % column},
]
)
],
"title": column,
}
for index, column in enumerate(["column a", "column b", "column c"])
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# replace object_list in context as we do not want it to be paginated
context["object_list"] = self.queryset
# see: https://github.com/riktar/jkanban#var-kanban--new-jkanbanoptions
context["kanban_options"] = {
"addItemButton": False,
"boards": self.get_kanban_data(context),
"dragBoards": False,
"dragItems": False,
}
return context
# rocket_report/admin.py
from wagtail.contrib.modeladmin.options import ModelAdmin
from .views import KanbanView
from .models import RocketReport
class KanbanMixin:
index_view_class = KanbanView
def get_index_template(self):
#...
class RocketReportAdmin(KanbanMixin, ModelAdmin):
model = RocketReport
# ...
{% comment %} templates/modeladmin/kanban_index.html (just the JS block shown) {% endcomment %}
{% block extra_js %}
{{ block.super }}
<script src="https://cdn.jsdelivr.net/npm/jkanban@1/dist/jkanban.js"></script>
{{ kanban_options|json_script:"kanban-options" }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// load the options from server
var options = JSON.parse(document.getElementById('kanban-options').textContent);
console.log('loaded', { options })
// build the kanban board with supplied options
// see: https://github.com/riktar/jkanban#var-kanban--new-jkanbanoptions
var kanban = new jKanban(Object.assign({}, options, {element: '#kanban-mount'}));
});
</script>
{% endblock %}
Model
. To achieve this we will revise the KanbanMixin
to have some methods for smaller templates (used for the title/content) and methods to determine what field will be used for the columns. After that, we can revise the View to prepare all the data.get_kanban_item_template
to look for a template with the name kanban_item
but also allow the Mixin usage to declare an attribute kanban_item_template_name
. This way we can have simple defaults but allow each KanbanMixin
to declare custom templates for the items on a per model basis.get_kanban_column_title_template
that is similar to the above but for the column title.get_kanban_column_field
which will return the first field name from the list_filter
attribute on the Mixin usage, this means we can leverage the existing ModelAdmin attributes approach.get_kanban_column_name_default
for a default column name, this will be used when there is no value for the Kanban column field (e.g. a drop-down where a None/blank value is selected).
# rocket_report/admin.py
class KanbanMixin:
index_view_class = KanbanView
def get_index_template(self):
# leverage the get_template to allow individual override on a per model basis
return self.index_template_name or self.get_templates("kanban_index")
def get_kanban_item_template(self):
# leverage the get_template to allow individual override on a per model basis
return getattr(
self, "kanban_item_template_name", self.get_templates("kanban_item")
)
def get_kanban_column_title_template(self):
# leverage the get_template to allow individual override on a per model basis
return getattr(
self,
"kanban_column_title_template_name",
self.get_templates("kanban_column_title"),
)
def get_kanban_column_field(self):
# return a field to use to determine which column the item will be shown in
# pull in the first value from list_filter if no specific column set
list_filter = getattr(self, "list_filter", [])
field = list_filter[0] if list_filter else None
return field
def get_kanban_column_name_default(self):
# used for the column title name for None or no column scenarios
return getattr(self, "kanban_column_name_default", "Other")
# ...
KanbanView
are to generate all the various parts (columns/items) and use the template in each of those parts set up in the KanbanMixin
.render_kanban_item_html
which will pull in the action buttons (part of ModelAdmin
), the template and then pass all the data to the template from the get_kanban_item_template
method. This will return a string (HTML) which will, in turn, be passed to the JSON data for the Kanban board.render_kanban_column_title_html
which will pass the context to the configured title template.get_kanban_columns
that uses a query which will gather ALL the Model instances and prepare the data which has groupings of those Models by their column, along with the columns (with their names) also.get_kanban_data
with a series of List parsing that goes through the column data and prepares the items to be placed within each column in a format that, when converted to JSON, is suitable for jKanban.
from django.contrib.admin.templatetags.admin_list import result_headers
from django.template.loader import render_to_string
from django.db.models import CharField, Count, F, Value
from wagtail.contrib.modeladmin.templatetags.modeladmin_tags import result_list
from wagtail.contrib.modeladmin.views import IndexView
class KanbanView(IndexView):
def render_kanban_item_html(self, context, obj, **kwargs):
"""
Allow for template based rendering of the content that goes inside each item
Prepare action buttons that will be the same as the classic modeladmin index
"""
kwargs["obj"] = obj
kwargs["action_buttons"] = self.get_buttons_for_obj(obj)
context.update(**kwargs)
template = self.model_admin.get_kanban_item_template()
return render_to_string(template, context, request=self.request,)
def render_kanban_column_title_html(self, context, **kwargs):
"""
Allow for template based rendering of the content that goes at the top of a column
"""
context.update(**kwargs)
template = self.model_admin.get_kanban_column_title_template()
return render_to_string(template, context, request=self.request,)
def get_kanban_columns(self):
"""
Gather all column related data
columns: name & count queryset
default: label of a column that either has None value or does not exist on the field
field: field name that is used to get the value from the instance
key: internal use key to refer to the annotated column name label value
queryset original queryset annotated with the column name label
"""
object_list = self.queryset
column_field = self.model_admin.get_kanban_column_field()
column_name_default = self.model_admin.get_kanban_column_name_default()
column_key = "__column_name"
queryset = object_list.annotate(
__column_name=F(column_field)
if column_field
else Value(column_name_default, output_field=CharField())
)
order = F(column_key).asc(nulls_first=True) if column_field else column_key
columns = (
queryset.values(column_key).order_by(order).annotate(count=Count("pk"))
)
return {
"columns": columns,
"default": column_name_default,
"field": column_field,
"key": column_key,
"queryset": queryset,
}
def get_kanban_data(self, context):
"""
Prepares the data that is used by the Kanban js library
An array of columns, each with an id, title (html) and item
Item value in each column contains an array of items which has a column, id & title (html)
"""
columns = self.get_kanban_columns()
# use existing model_admin utility to build headers/values
result_data = result_list(context)
# set up items (for ALL columns)
items = [
{
"column": getattr(obj, columns["key"]),
"id": "item-id-%s" % obj.pk,
"title": self.render_kanban_item_html(
context,
obj,
fields=[
{"label": label, "value": result_data["results"][index][idx]}
for idx, label in enumerate(result_data["result_headers"])
],
),
}
for index, obj in enumerate(columns["queryset"])
]
# set up columns (aka boards) with sets of filtered items inside
return [
{
"id": "column-id-%s" % index,
"item": [
item for item in items if item["column"] == column[columns["key"]]
],
"title": self.render_kanban_column_title_html(
context,
count=column["count"],
name=column.get(columns["key"], columns["default"])
or columns["default"],
),
}
for index, column in enumerate(columns["columns"])
]
def get_context_data(self, **kwargs):
# ... (same as before)
<td>
tags due to existing ModelAdmin
assumptions.INSTALLED_APPS