Source code for ferrosoft.apps.ferrobase.tables.columns

"""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 WidgetColumn(Column): """Render any django.forms Widget in a column.""" def __init__( self, widget: Widget, name: Callable | str | None = None, widget_attrs: Callable | dict[str, Any] | None = None, *args, **kwargs, ): """Configure the widget and its name/attributes resolution. Args: widget: A Django form widget instance to render per row. name: Static ``name`` attribute or a callable receiving the same arguments as :meth:`render`. Defaults to the column class name in lowercase. widget_attrs: Static attribute dict or a callable returning one, merged into the widget call. *args: Forwarded to :class:`Column`. **kwargs: Forwarded to :class:`Column`. """ super().__init__(*args, **kwargs) self.name = name self.widget = widget self.widget_attrs = widget_attrs
[docs] def render(self, *args, **kwargs): value = super().render(*args, **kwargs) attrs = None if callable(self.widget_attrs): attrs = self.widget_attrs(*args, **kwargs) elif isinstance(self.widget_attrs, dict): attrs = self.widget_attrs elif self.widget_attrs is not None: raise ValueError("widget_attrs should be callable or dict") if callable(self.name): name = self.name(*args, **kwargs) elif isinstance(self.name, str): name = self.name elif self.name is None: name = self.__class__.__name__.lower() else: raise ValueError("name should be callable or str") # Make Column args/kwargs available to Widget w/o breaking signature. self.widget.render_args = args self.widget.render_kwargs = kwargs return self.widget.render(name, value, attrs=attrs)
[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] @classmethod def input_name_with_pk(cls, base_name: str): """ Create input names with record's pk as subscript. Example: If base_name is foo and a record's pk is 1234, the resulting input name is foo[1234]. Args: base_name (str): the part of input name before subscript Returns: lambda that's suitable for input_name attribute. """ return lambda record: "%s[%s]" % (base_name, str(record.pk))
[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