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.