A Daterange Mixin for Django Views

This week I connected the bootstrap-datepicker JavaScript library's input-daterange widget to PMT using a mixin, so it can easily be made to work with any view. The complete changes are in dmt#699.

As shown in the pull request, we already had the RangeOffsetMixin which allows users to specify the reported days using "range" and "offset" parameters. This worked and did everything we needed it to do. But it wasn't very intuitive: If I want a staff report for the month of December 2015, I find the right parameters through a procedure of trial and error (let's see.. "range" will be 31 because I think December has 31 days, and "offset" will be around 35 + 31 because right now it's February 5th, and December 1st was a little over two months ago). Because we think in terms of calendar dates, not durations and offsets of days, Maurice suggested we allow the users to pick this range using a datepicker like we use to pick an action item's due date. I thought this was a great idea that we should have come up with sooner.

Fortunately, the same JavaScript library we're using to pick due dates has a "daterange" mode: input-daterange. After changing the interface in PMT's reports templates, I just needed to connect the view to look at these new form parameters on submit.

I've adapted the old RangeOffsetMixin into a DaterangeMixin. RangeOffsetMixin calculates dates using a calc_interval() method, which I assumed was no longer necessary: the user is now picking absolute, not relative, points of time, simplifying the server's date calculation job. This is a nice situation where a more user-friendly interface also lends to simpler code.

It turns out that there is still some calculation to do that's worth separating from get_params(). I still need to combine interval_start and interval_end dates with datetime.min.time() and datetime.max.time() respectively for the range to be accurate. The results of these combinations are timezone-naive, so I then need to convert them to timezone-aware dates as we were doing before.

class DaterangeMixin(object):
    """A mixin for views that use daterange-datepicker.
    This is meant to be attached to a View. The mixin
    calculates dates using two keyword arguments in the
    GET request:
    - interval_start
    - interval_end
    """

    interval_start = None
    interval_end = None
    _today = None

    def today(self):
        if not self._today:
            self._today = date.today()
        return self._today

    def calc_interval(self):
        # Calculate from the beginning of the first day
        # in the range to the end of the last day.
        naive_start = datetime.combine(
            self.interval_start, datetime.min.time())
        naive_end = datetime.combine(
            self.interval_end, datetime.max.time())

        # Convert to TZ-aware, based on the current timezone.
        aware_start = pytz.timezone(
            settings.TIME_ZONE
        ).localize(
            naive_start, is_dst=None)
        aware_end = pytz.timezone(settings.TIME_ZONE).localize(
            naive_end, is_dst=None)

        self.interval_start = aware_start
        self.interval_end = aware_end

    def get_params(self):
        """Update the interval based on request params."""

        self.interval_start = self.request.GET.get(
            'interval_start', None)
        self.interval_end = self.request.GET.get(
            'interval_end', None)

        try:
            self.interval_start = parse_date(
                self.interval_start)
        except TypeError:
            pass
        try:
            self.interval_end = parse_date(self.interval_end)
        except TypeError:
            pass

        if not self.interval_start:
            self.interval_start = self.today() - \
                relativedelta(months=1)
        if not self.interval_end:
            self.interval_end = self.today()

        self.calc_interval()

    def get_context_data(self, *args, **kwargs):
        self.get_params()
        context = super(DaterangeMixin, self).get_context_data(
            *args, **kwargs)
        context.update({
            'interval_start': self.interval_start,
            'interval_end': self.interval_end,
        })
        return context

So the code isn't as simple as I hoped, but I think it can be improved. I'm going through extra, possibly redundant, steps to make sure interval_start or interval_end never fall back to something that the user might not expect: an example of complicated code actually yielding a more user-friendly experience.

I mentioned before that complicated code isn't as satisfying to end up with as brief, clear code. I think that's because as more happens in our own code, a few things come along with that. The room for error increases, and the amount of code that you need to read, understand, and maintain increases.

I think the relationship between code and interface is interesting when I'm reminded that code has its own interfaces, and is itself an interface to our computers. Is "simplicity" of Django code less important than I think it is? After all, this is an arbitrarily opinionated web framework built on Python and its arbitrarily opinionated standard libraries. Well that's the open question of this post and in the meantime we have mccabe to keep our code complexity in check.