Calendar-like views in Django admin

· 700 words · 4 minute read

In my coding work for SeekHealing, we make extensive use of the Django Admin for our program management database. For a one-person coding team, Django has been invaluable for maintaining a functional, accessible system that evolves with the program’s needs despite the limited number of hours I have to devote to the work.

We manage our calendar of program events in Django, with a model appropriately called CalendarEvent, which has a start-time and an end-time - and we’ve got data in this database going back to 2018. Events are planned out two months in advance, but the most active event management work is done on the events in handful of days before-and-after the current date. From a user-experience standpoint, how can we provide optimal navigation for calendar event management for the staff?

Listing the events in chronological order seems like a good first place to start. Doing so in ascending order, with the earliest events nearly 5 years ago, doesn’t make any sense. Doing so in descending order might seem like a great idea, but because we plan events two months into the future, finding the set of events around the current date means hunting down the list across dozens of events further in the future.

From user interviews, what came out as providing the most intuitive UX was to make a customized template for the list view that shows up in a calendar form, month-by-month, with the present month selected by default, with navigation aiding the ability to move to the next and previous month.

I won’t go into the calendar based layout in this post, suffice to say that the Python stdlib calendar module proved most helpful. But I do want to share about the month-by-month navigation.

My first attempt was to override the ModelAdmin class such that if you went to the changelist_view without specifying any GET parameters, it filled them in with the current month.

# events/admin.py

from urllib.parse import urlencode

from django.contrib import admin
from django.http import HttpResponseRedirect
from events import models
from django.utils.timezone import localdate

class CalendarEventAdmin(admin.ModelAdmin):
    model = models.CalendarEvent
    change_list_template = "admin/events/calendarevent/calendarevent_list.html"
    ordering = ["start_time"]
    date_hierarchy = "start_time"

    def changelist_view(self, request, extra_context={}):
        if request.GET:
            return super().changelist_view(request, extra_context)
        date = localdate()
        params = ["month", "year"]
        field_keys = ["{}__{}".format(self.date_hierarchy, i) for i in params]
        field_values = [getattr(date, i) for i in params]
        query_params = dict(zip(field_keys, field_values))
        url = "{}?{}".format(request.path, urlencode(query_params))
        return HttpResponseRedirect(url)

… which initially seemed to work. Except with the Django admin date_hierarchy navigator, it listed breakdowns to the day-level. To get to the next or previous month, you have to step back out to the year only view and select the proper month - that’s not ideal, but it would work… mostly. In December 2023, if you want to see January 2024, you can’t, because when you step back out in the date_hierarchy to select the year, it would automatically redirect you back to the current month, as no GET querystring parameters would remain.

So I knew I had to modify the way date_hierarchy worked. And for that, I wrote my own template tag based on the date_hierarchy template tag:

# events/templatetags/calendar_tags.py

import datetime

from dateutil import relativedelta
from django.contrib.admin.templatetags.base import InclusionAdminNode
from django.template import Library
from django.utils import format
from django.utils.text import capfirst
from django.utils.timezone import localdate

register = Library()

def month_explorer(cl):
    if cl.date_hierarchy:
        field_name = cl.date_hierarchy
        year_field = "%s__year" % field_name
        month_field = "%s__month" % field_name
        field_generic = "%s__" % field_name
        year_lookup = cl.params.get(year_field)
        month_lookup = cl.params.get(month_field)

        def link(filters):
            return cl.get_query_string(filters, [field_generic])

        if not (year_lookup and month_lookup):
            relative_to_date = localdate()
        else:
            relative_to_date = datetime.date(int(year_lookup), int(month_lookup), 1)
        next_date = relative_to_date + relativedelta.relativedelta(months=1)
        prev_date = relative_to_date - relativedelta.relativedelta(months=1)
        return dict(
            show=True,
            back=None,
            choices=[
                dict(
                    link=link({year_field: prev_date.year, month_field: prev_date.month}),
                    title="« " + capfirst(formats.date_format(prev_date, "YEAR_MONTH_FORMAT")),
                ),
                dict(
                    link=link({year_field: next_date.year, month_field: next_date.month}),
                    title=capfirst(formats.date_format(next_date, "YEAR_MONTH_FORMAT")) + " »",
                ),
            ],
        )


@register.tag(name="month_explorer")
def month_explorer_tag(parser, token):
    return InclusionAdminNode(
        parser,
        token,
        func=month_explorer,
        template_name="date_hierarchy.html",
        takes_context=False,
    )
<!-- admin/events/calendarevent/calendarevent_list.html -->
{% load calendar_tags %}

{% block date_hierarchy %}{% month_explorer cl %}{% endblock %}

With these changes, the changelist_view in the Django admin defaults to loading the current month’s events in the calendar, and in the date hierarchy navigator, it includes links to the previous and following month, making for easy navigation.