Last active
July 22, 2022 04:23
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#! /usr/bin/env python | |
# -*- coding: utf-8 -*- | |
# | |
# >> | |
# vidangel-backend, 2022 | |
# Blake VandeMerwe <[email protected]> | |
# << | |
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, | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage example,