Skip to content

Instantly share code, notes, and snippets.

@blakev
Last active July 22, 2022 04:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save blakev/e45aba2925e18db7383f4f287adc3200 to your computer and use it in GitHub Desktop.
Save blakev/e45aba2925e18db7383f4f287adc3200 to your computer and use it in GitHub Desktop.
Django admin.ModelAdmin `list_filter` meta type classes for relative time and boolean values
#! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# >>
# vidangel-backend, 2022
# Blake VandeMerwe <blake@vidangel.com>
# <<
import re
from datetime import timedelta
from logging import getLogger
from typing import Any, Sequence, Type
import humanize
from django.contrib import admin
from django.utils import timezone
from toolz.functoolz import curry
from django.http import HttpRequest
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as lazy
__all__ = [
'boolean_filter',
'relative_filter',
'relative_datetime_column',
]
logger = getLogger(__name__)
TIME_PAIR_RE = re.compile(r'(\d+)([mhsdw])', re.I)
REVERSE_PERIOD = {
's': 'seconds',
'm': 'minutes',
'h': 'hours',
'd': 'days',
'w': 'weeks',
}
def boolean_filter(
title: str,
parameter_name: str,
conditions: Sequence[str],
) -> Type[admin.SimpleListFilter]:
"""Creates a new dynamic type for boolean quick-sort SimpleListFilter."""
def bool_queryset(
this: admin.SimpleListFilter,
request: HttpRequest,
queryset: QuerySet,
) -> QuerySet:
"""Create a filter on a queryset that eliminates items based on boolean
conditions."""
orig_p_val = this.value()
if not orig_p_val:
return queryset
p_val = orig_p_val.lower()[0]
if p_val in ('t', 'y', '1'):
f_val = True
elif p_val in ('f', 'n', '0'):
f_val = False
else:
raise ValueError(f'invalid value for {parameter_name}, {orig_p_val}')
kw = {}
for condition in conditions:
if condition.startswith('~'):
condition = condition[1:]
value = not f_val
else:
value = f_val
kw[condition] = value
return queryset.filter(**kw)
cls_name = re.sub(r'[_\-.\w]', '', title.title())
cls_type = type(
f'Dyn{cls_name}Filter',
(admin.SimpleListFilter,),
{
'title': lazy(title),
'parameter_name': parameter_name,
'queryset': bool_queryset,
'lookups': lambda *_: (
('t', lazy(f'is {title}')),
('f', lazy(f'is not {title}')),
)
},
)
assert issubclass(cls_type, admin.SimpleListFilter)
return cls_type
def relative_filter(
title: str,
parameter_name: str,
column_name: str,
show_values: Sequence[str],
) -> Type[admin.SimpleListFilter]:
"""Creates a new dynamic type for a relative quick-sort SimpleListFilter.
Example:
list_filter = (
relative_filter(
'recently matched',
'recent_match',
'matched_at',
['10m', '30m', '1h', '3h', '12h', '1d', '3d'],
),
...,
...,
)
yields:
> By recently matched
> All
> 10 minutes ago
> 30 minutes ago
> an hour ago
> .. etc ..
"""
def humanize_short_periods(*vals: str) -> tuple[tuple[str, Any], ...]:
"""Turns short values into a long description, displayed in the gui."""
def inner():
for val in vals:
if m := TIME_PAIR_RE.match(val):
a, b = m.groups()
diff = timedelta(**{REVERSE_PERIOD[b]: int(a)})
desc = humanize.naturaldelta(diff, months=False)
yield val, lazy(f'{desc} ago')
else:
raise ValueError(f'cannot convert time definition {val}')
return tuple(inner())
cls_name = re.sub(r'[_\-.\w]', '', column_name.title())
cls_type = type(
f'Dyn{cls_name}Filter',
(admin.SimpleListFilter,),
{
'title': lazy(title),
'parameter_name': parameter_name,
'lookups': lambda *_: humanize_short_periods(*show_values),
'queryset': relative_datetime_column(column_name),
},
)
assert issubclass(cls_type, admin.SimpleListFilter)
return cls_type
@curry
def relative_datetime_column(
bind_column: str,
this: admin.SimpleListFilter,
request: HttpRequest,
queryset: QuerySet,
) -> QuerySet:
"""Bind a single column to be filtered by relative time, e.g. 'The last 10 minutes'.
This is bound to a simple DSL that excepts ``NNs`` where ``s`` can be one of
_s_econds, _m_inutes, _h_ours, _d_ays, or _w_eeks.
"""
p_val = this.value()
if not p_val:
return queryset
pairs = TIME_PAIR_RE.findall(p_val)
if not pairs:
return queryset
total = {}
for val, period in pairs:
key = REVERSE_PERIOD[period]
total.setdefault(key, 0)
total[key] += int(val)
diff = timedelta(**total)
logger.info(f'turned {p_val} into {diff}')
return queryset.filter(**{
f'{bind_column}__gte': timezone.now() - diff,
})
@blakev
Copy link
Author

blakev commented Jul 21, 2022

Usage example,

class ContentRequestAdmin(admin.ModelAdmin):
    ...
    ...
    list_filter = (
        relative_filter(
            'recently matched',  # in gui
            'recent_match',      # querystring parameter
            'matched_at',        # database column
            ['10m', '30m', '1h', '3h', '12h', '1d', '3d'],
        ),
        boolean_filter(
            'matched',
            'matched',
            ('~content_match__isnull', '~matched_at__isnull'),
        )
    )

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment