"""Reusable django-tables2 column classes for Ferrosoft tables.
The columns add rendering for domain-specific values (currency, units,
dates, arrays, inline-edit widgets) as well as layout helpers (fixed-width
classes, summing footers, action links) on top of django-tables2's base
``Column``.
"""
from collections.abc import Callable
from decimal import Decimal
import logging
from typing import Any
from zoneinfo import ZoneInfo
from django.forms import Widget
import django_tables2 as tables2
from django.template.defaultfilters import date, time
from django.templatetags.l10n import localize
from django.utils.html import format_html, escape
from django.utils.safestring import mark_safe
from ferrosoft.apps.date_time import Month, month_names, user_zoneinfo
from ferrosoft.apps.ferrobase.models.decimal import DecimalWithUnit
from ferrosoft.decimal import zero
NUMBER_COLUMN_ATTRS = {
"th": {"class": "number-column"},
"td": {"class": "number-column"},
}
def _add_classes(arguments: dict, element_class_mapping: list) -> dict:
if "attrs" in arguments:
attrs = arguments["attrs"]
del arguments["attrs"]
else:
attrs = {}
for element, new_class in element_class_mapping:
th_attrs = attrs[element] if element in attrs else {}
th_class = th_attrs["class"] if "class" in th_attrs else ""
th_attrs.update({"class": " ".join([th_class, new_class])})
attrs.update(
{
element: th_attrs,
}
)
return attrs
[docs]
class Column(tables2.Column):
"""Base column adding fixed-width sizing and stackable render mixins.
All Ferrosoft columns inherit from this class so they share the same
``fixed_width`` (CSS ``cw-{n}`` class) shortcut and mixin pipeline.
"""
def __init__(
self,
fixed_width: int = None,
mixins=None,
*args,
**kwargs,
):
"""Configure width and render-mixin chain.
Args:
fixed_width: When set, applies the ``cw-{fixed_width}`` CSS class
to both the ``th`` and ``td`` cells.
mixins: Tuple of ``Column`` instances whose ``render`` methods are
chained after the column's own value, each receiving the
running ``rendered_value`` as a keyword argument.
*args: Forwarded to ``django_tables2.Column``.
**kwargs: Forwarded to ``django_tables2.Column``.
"""
self.mixins = mixins
if fixed_width is None:
super().__init__(*args, **kwargs)
else:
attrs = _add_classes(
kwargs,
[
("th", "cw-" + str(fixed_width)),
("td", "cw-" + str(fixed_width)),
],
)
super().__init__(attrs=attrs, *args, **kwargs)
[docs]
def render(self, value, *args, **kwargs):
"""Pipe ``value`` through each mixin's ``render`` and return the result."""
rendered_value = value
if self.mixins is not None:
for mixin in self.mixins:
if isinstance(mixin, Column):
rendered_value = mixin.render(
value=value,
rendered_value=rendered_value,
*args,
**kwargs,
)
return rendered_value
[docs]
class ArrayColumn(Column):
"""Render array values (of PostgreSQL array columns)"""
[docs]
def render(self, value, *args, **kwargs):
"""Join array elements with a comma and space."""
return ", ".join(value)
[docs]
class DefaultValue(Column):
"""Column that substitutes a configured default when the value is ``None``."""
def __init__(self, default_value, *args, **kwargs):
"""Store the fallback value used in :meth:`render`."""
super().__init__(*args, **kwargs)
self.default_value = default_value
[docs]
def render(self, value, *args, **kwargs):
"""Return ``value`` or ``default_value`` when ``value`` is ``None``."""
return value if value is not None else self.default_value
[docs]
class DateTimeColumn(Column):
"""Render a datetime in the requesting user's timezone."""
def __init__(self, format_string: str = None, *args, **kwargs):
"""Configure formatting.
Args:
format_string: Optional ``strftime`` format. When omitted, Django's
localized ``date`` + ``time`` filters are used instead.
"""
super().__init__(*args, **kwargs)
self.format_string = format_string
self._user_tzinfo = None
self._utc_timezone = ZoneInfo("UTC")
[docs]
def render(self, value, *args, **kwargs):
"""Format ``value`` in the user's timezone.
Requires the table instance (with attached ``request``) to be passed
via ``kwargs['table']`` so the user's preferred timezone can be
resolved on the first call and cached on the column.
"""
if self._user_tzinfo is None:
table: object = kwargs.pop("table", None)
if not table:
raise ValueError("table instance required")
request = getattr(table, "request", None)
if not request:
raise ValueError("request required in table instance")
self._user_tzinfo = user_zoneinfo(request.user)
user_time = value.astimezone(self._user_tzinfo)
return (
"%s %s" % (date(user_time), time(user_time))
if self.format_string is None
else user_time.strftime(self.format_string)
)
[docs]
class NewlineToHTMLBreak(Column):
"""Mixin column converting embedded newlines to ``<br>`` while HTML-escaping."""
_break = mark_safe("<br>")
[docs]
def render(self, rendered_value, *args, **kwargs):
"""Escape ``rendered_value`` then replace ``\\n`` with ``<br>``."""
return mark_safe(escape(rendered_value).replace("\n", "<br>"))
[docs]
class NumberColumn(Column):
"""Right-aligned number column with optional unit symbol and transforms."""
def __init__(
self,
with_symbol=False,
quantize: Decimal = None,
transform=None,
transform_value=None,
*args,
**kwargs,
):
"""Configure number formatting and optional unit symbol.
Args:
with_symbol: ``True`` to expect string ``"<number> <symbol>"`` or
a ``DecimalWithUnit`` value and render both parts; a ``str``
to render every value with that fixed symbol; ``False`` to
render the bare number.
quantize: If supplied, the rendered value is quantised with
:meth:`decimal.Decimal.quantize`.
transform: Callable applied to the parsed ``Decimal`` before
quantisation/localisation.
transform_value: Callable applied to the raw value handed to
:meth:`render` *before* any parsing.
*args: Forwarded to :class:`Column`.
**kwargs: Forwarded to :class:`Column`.
"""
self.quantize = quantize
self.with_symbol = with_symbol
self.transform = transform
self.transform_value = transform_value
attrs = _add_classes(
kwargs,
[
("th", "number-column"),
("td", "number-column"),
],
)
super().__init__(
attrs=attrs,
*args,
localize=kwargs.pop("localize", True),
**kwargs,
)
[docs]
def render(self, value, **kwargs):
symbol = ""
if callable(self.transform_value):
value = self.transform_value(value)
if isinstance(self.with_symbol, str):
number = Decimal(value)
symbol = self.with_symbol
elif isinstance(self.with_symbol, bool) and self.with_symbol:
if isinstance(value, str):
# The part before whitespace is number, the part after it standard unit symbol.
ws_index = value.find(" ")
if ws_index == -1:
raise Exception(
'whitespace expected in number with symbol: "%s"' % value
)
number_raw = value[:ws_index]
number = zero if number_raw == "" else Decimal(number_raw)
symbol = value[ws_index + 1 :]
elif isinstance(value, DecimalWithUnit):
number = value.value
symbol = "" if value.unit is None else value.unit.symbol
else:
raise ValueError("column value must be string or DecimalWithUnit")
elif value is None:
number = zero
logging.warning("number is None: %s" % self)
else:
number = Decimal(value)
if callable(self.transform):
number = self.transform(number)
if self.quantize is not None:
number = number.quantize(self.quantize)
if self.localize:
number = localize(number)
if self.with_symbol:
return format_html(
'<span class="number-symbol"><span class="n">{number}</span> <span class="s">{symbol}</span></span>',
number=number,
symbol=symbol,
)
else:
return format_html(
'<span class="number">{number}</span>',
number=number,
)
[docs]
class InlineEditColumn(Column):
"""Column rendering a read-only value alongside an editable input/select.
The template emits both a view-mode anchor and a form control so the cell
can switch between display and edit modes client-side.
"""
_inline_edit_template = """<div class="inline-edit">
%s<a href="#" class="view">{value}</a>%s
{form_element}
</div>
"""
_input_template = """<input {input_attrs} class="form-control input" name="{input_name}" type="{input_type}" value="{value_raw}">"""
_select_template = """<select {input_attrs} class="form-select input" name="{input_name}">{options}</select>"""
_option_template = """<option {selected} value="{value}">{label}</option>"""
def __init__(
self,
input_name="",
input_attrs: dict = None,
number_display: bool = False,
quantize: Decimal = None,
options=None,
form_name: str = None,
read_only: bool = False,
*args,
**kwargs,
):
"""Configure the editable control.
Args:
input_name: ``name`` attribute of the input, or a callable
receiving the row record (see :meth:`input_name_with_pk`).
input_attrs: Extra HTML attributes to splat onto the input.
number_display: When ``True`` renders the value right-aligned in
a monospace ``number-column`` cell.
quantize: If supplied, the value is quantised before display.
options: ``[(value, label), ...]`` to render a ``<select>``
instead of a text/number input.
form_name: When set, adds ``form="<form_name>"`` so the input
submits as part of a form elsewhere on the page.
read_only: Render the value only, without an input.
*args: Forwarded to :class:`Column`.
**kwargs: Forwarded to :class:`Column`.
"""
if number_display:
attrs = _add_classes(
kwargs,
[
("th", "number-column"),
("td", "number-column"),
],
)
super().__init__(attrs=attrs, *args, **kwargs)
else:
super().__init__(*args, **kwargs)
self.number_display = number_display
self.quantize = quantize
self.input_attrs = input_attrs
self.input_name = input_name
self.options = options
self.form_name = form_name
self.read_only = read_only
[docs]
@staticmethod
def step_attr(step="0.001"):
"""Return an ``input_attrs`` dict setting the HTML ``step`` attribute."""
return {"step": step}
[docs]
def render(self, record, value, **kwargs):
if self.quantize is not None:
value = Decimal(value).quantize(self.quantize)
number_pre = ""
number_post = ""
if self.number_display:
number_pre = '<div class="number">'
number_post = "</div>"
if self.read_only:
return mark_safe("%s%s%s" % (number_pre, localize(value), number_post))
if callable(self.input_name):
real_input_name = self.input_name(record)
elif isinstance(self.input_name, str):
real_input_name = self.input_name
else:
raise ValueError("input_name must be string or callable")
if isinstance(self.input_attrs, dict):
attrs = [
'%s="%s"' % (format_html(key), format_html(value))
for key, value in self.input_attrs.items()
]
else:
attrs = []
if self.form_name:
attrs.append('form="%s"' % format_html(self.form_name))
if self.options:
options = []
for opt_value, label in self.options:
options.append(
format_html(
self._option_template,
value=opt_value,
label=label,
selected="selected" if value == label else "",
)
)
form_element = format_html(
self._select_template,
input_name=real_input_name,
options=mark_safe("".join(options)),
input_attrs=mark_safe(" ".join(attrs)),
)
else:
form_element = format_html(
self._input_template,
value_raw=value,
input_name=real_input_name,
input_type="number" if self.number_display else "text",
input_attrs=mark_safe(" ".join(attrs)),
)
return format_html(
self._inline_edit_template % (number_pre, number_post),
value=localize(value),
form_element=form_element,
)
[docs]
class SummingColumn(Column):
"""Column that aggregates all visible rows and shows the sum in the footer."""
def __init__(self, *args, **kwargs):
"""Pop the optional ``quantize`` kwarg (default ``Decimal(1)``)."""
self.quantize = kwargs.pop("quantize", Decimal(1))
super().__init__(*args, **kwargs)
[docs]
class EmptyExpandColumn(Column):
"""Empty placeholder column that stretches to fill remaining width."""
def __init__(self, *args, **kwargs):
"""Configure the column with an ``expand`` CSS class and empty label."""
super().__init__(
attrs={"th": {"class": "expand"}},
default="",
verbose_name="",
*args,
**kwargs,
)
[docs]
def render(self, value, *args, **kwargs):
"""Always render as an empty string."""
return ""
[docs]
class ActionColumn(Column):
"""
Display a action in the column, in the form of a link.
The link can have arbitrary attributes, allowing for HTMX interactivity.
"""
_template = """<a href="{href}" {attributes}>{font_icon}{label}</a>"""
_font_icon_template = """<i class="{icon_class}"></i> """
def __init__(
self,
href=None,
label=None,
attributes=None,
font_icon=None,
*args,
**kwargs,
):
"""Configure the rendered anchor.
Args:
href: Static URL or callable receiving the render arguments.
Defaults to ``"#"`` when omitted.
label: Static label string or callable. When ``None`` the column
value itself is used as the anchor text.
attributes: Static dict of HTML attributes or a callable. Boolean
values follow HTML5 boolean-attribute conventions.
font_icon: Optional Bootstrap icon class name; rendered as a
leading ``<i>`` element when set.
*args: Forwarded to :class:`Column`.
**kwargs: Forwarded to :class:`Column`.
"""
super().__init__(
*args,
**kwargs,
)
self.attributes = attributes or {}
self.href = href or "#"
self.label = label
self.font_icon = font_icon
@staticmethod
def _format_attributes(attrs: dict) -> str:
attrs_formatted = []
for key, value in attrs.items():
if isinstance(value, bool):
if value:
attrs_formatted.append(format_html("{}", key))
else:
attrs_formatted.append(format_html('{}="{}"', key, str(value)))
return mark_safe(" ".join(attrs_formatted))
[docs]
def render(self, *args, **kwargs):
if self.label is None:
label = super().render(*args, **kwargs)
elif callable(self.label):
label = self.label(*args, **kwargs)
elif isinstance(self.label, str):
label = self.label
else:
label = str(self.label)
if callable(self.href):
href = self.href(*args, **kwargs)
elif isinstance(self.href, str):
href = self.href
else:
href = str(self.href)
if callable(self.attributes):
attributes = self.attributes(*args, **kwargs)
else:
attributes = self.attributes
font_icon = (
format_html(
self._font_icon_template,
icon_class=self.font_icon,
)
if self.font_icon
else ""
)
return format_html(
self._template,
attributes=self._format_attributes(attributes),
href=href,
font_icon=font_icon,
label=label,
)
[docs]
class MonthColumn(Column):
"""Render a 1-12 month number as its localised month name."""
[docs]
def render(self, value, *args, **kwargs):
"""Map ``value`` to a localised name, falling back to the raw value."""
try:
month = Month(int(value))
return month_names[month]
except:
return value