# Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
"""``django-tables2`` table classes for emiflow list and detail views.
Each table maps a model (or a flat record produced by an aggregation) to
the columns shown in templates, with column types drawn from
:mod:`ferrosoft.apps.ferrobase.tables` for shared rendering behaviour
(number formatting, action buttons, linkified cells). Tables ending in
``CRUDTable`` add an *actions* column with edit/delete buttons whose
delete URL each subclass resolves in ``get_delete_action_url``.
"""
from decimal import Decimal
from django.forms import NumberInput
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django_tables2 import tables, Column
from ferrosoft.apps.emiflow.models import (
TransportChain,
FreightItem,
Treatment,
TreatmentOperation,
TransportChainElement,
EmissionBookingRecord,
Emitter,
TreatmentLine,
TreatmentOperationOutputLine,
TreatmentOperationInputLine,
Report,
LCCValue,
TransportLCCValue,
EnergyCarrier,
EmissionFactor,
)
from ferrosoft.apps.emiflow.models.operation import TransportOperation
from ferrosoft.apps.emiflow.models.treatment import (
TreatmentInputLine,
TreatmentOutputLine,
TreatmentLCCValue,
)
from ferrosoft.apps.emiflow.templatetags.emiflow import emission_grade
from ferrosoft.apps.ferrobase.models import BusinessPartner, Material
from ferrosoft.apps.ferrobase.tables import (
Column as FerrobaseColumn,
NumberColumn,
ArrayColumn,
WidgetColumn,
Table,
CRUDTable,
EmptyExpandColumn,
)
from ferrosoft.apps.ferrobase.views.generic import Permissions, Verb
[docs]
class MultiplyProportion(FerrobaseColumn):
"""Render-mixin that converts a 0..1 proportion into a two-decimal percentage."""
[docs]
def render(self, value, *args, **kwargs):
"""Multiply ``value`` by 100 and quantize to two decimal places."""
return (value * Decimal(100)).quantize(Decimal("1.00"))
[docs]
class AllocationEditorTable(tables.Table):
"""Inline-editable allocation table for treatment-operation output lines.
Renders each output material's weight alongside a ``NumberInput`` so
operators can adjust the percentage allocated to that material; inputs
are tagged with ``data-material-id`` to be picked up by the
client-side validation script that enforces summation to 100 %.
"""
weight = NumberColumn(with_symbol=True)
allocated_fraction = WidgetColumn(
mixins=(MultiplyProportion(),),
name=lambda record, *args, **kwargs: f"fraction[{str(record.material_id)}]",
verbose_name=_("Proportion %"),
widget=NumberInput(
attrs={
"class": "form-control proportionInput",
"min": "0",
"max": "100",
}
),
widget_attrs=lambda record, *args, **kwargs: {
"data-material-id": str(record.material_id),
},
)
[docs]
class EmissionGradeColumn(Column):
"""Render a letter-grade emission badge from an emission-intensity value.
The underlying ``grade`` is a Python ``@property`` rather than a real
database column, so this column is forced to be non-orderable.
"""
def __init__(self, *args, **kwargs):
# Grade is a @property value, cannot be ordered.
kwargs.update({"orderable": False})
super().__init__(*args, **kwargs)
[docs]
def render(self, value):
"""Delegate to the ``emission_grade`` template tag for the badge HTML."""
return emission_grade(value)
[docs]
class EmissionBookingRecordTable(tables.Table):
"""List view table for :class:`EmissionBookingRecord` showing emission, weight, and partner columns."""
entity_reference = Column(
verbose_name=_("Reference"),
)
emission_total_value = NumberColumn(
verbose_name=_("Emission Total"),
)
emission_operation_value = NumberColumn(
verbose_name=_("Emission Operation"),
)
emission_unit_symbol = Column(
verbose_name=_("Emission Unit"),
)
weight_value = NumberColumn(
verbose_name=_("Weight"),
)
weight_unit_symbol = Column(
verbose_name=_("Weight Unit"),
)
material_code = ArrayColumn(
verbose_name=_("Material Code"),
)
material_name = ArrayColumn(
verbose_name=_("Material Name"),
)
[docs]
class EmissionFactorTable(CRUDTable):
"""CRUD table listing emission factors with category, value, and unit columns."""
[docs]
def get_delete_action_url(self, *args, **kwargs) -> str | None:
"""Return the URL of the delete view for the row's emission factor."""
return reverse("emiflow:emissionfactor_delete", args=[kwargs.get("record").pk])
[docs]
class EmitterTable(Table):
"""List table for :class:`Emitter` with a linkified name and emission-grade badge."""
name = Column(linkify=True)
emission_intensity__grade = EmissionGradeColumn()
[docs]
class EnergyCarrierTable(Table):
"""List table for :class:`EnergyCarrier` showing composition, heating value, and density."""
name = Column(linkify=True)
[docs]
class FreightItemTable(CRUDTable):
"""CRUD table listing freight items with material, weight, and unit columns.
The material link is dropped before render when the current user lacks
update permission on :class:`Material`, so unprivileged users see a
plain label rather than a non-functional link.
"""
material = Column(linkify=True)
empty = EmptyExpandColumn()
weight_value = NumberColumn()
[docs]
def before_render(self, request):
"""Strip the material link when the user cannot update materials."""
super().before_render(request)
if not Permissions.allowed(request, Material, Verb.UPDATE):
self.columns["material"].link = None
[docs]
def get_delete_action_url(self, *args, **kwargs) -> str | None:
"""Return the URL of the delete view for the row's freight item."""
return reverse("emiflow:freightitem_delete", args=[kwargs.get("record").pk])
[docs]
class LCCValueTable(CRUDTable):
"""Base CRUD table for lifecycle-category contribution values.
Subclassed by :class:`TransportLCCValueTable` and
:class:`TreatmentLCCValueTable`; each subclass swaps the model and
delete-URL resolution while keeping the column layout.
"""
operation_emission = NumberColumn(
with_symbol=True,
quantize=Decimal("1.00"),
)
total_emission = NumberColumn(
with_symbol=True,
quantize=Decimal("1.00"),
)
empty = EmptyExpandColumn()
[docs]
class ReportTable(Table):
"""List table for :class:`Report` with a linkified reference column."""
reference = Column(linkify=True)
[docs]
class TransportChainTable(Table):
"""List table for :class:`TransportChain` with linkified reference, consignor, and consignee.
Consignor/consignee links are stripped before render when the current
user lacks update permission on :class:`BusinessPartner`.
"""
reference = Column(linkify=True)
consignor = Column(linkify=True)
consignee = Column(linkify=True)
[docs]
def before_render(self, request):
"""Strip consignor/consignee links when the user cannot update partners."""
super().before_render(request)
if not Permissions.allowed(request, BusinessPartner, Verb.UPDATE):
self.columns["consignor"].link = None
self.columns["consignee"].link = None
[docs]
class TransportChainElementTable(Table):
"""Read-only table listing the ordered elements of a transport chain."""
activity_distance_value = NumberColumn(
verbose_name=_("Distance"),
)
activity_distance_unit = Column(
verbose_name=_("Distance Unit"),
)
operation__emission_intensity__grade = EmissionGradeColumn()
[docs]
class TransportChainElementActionTable(CRUDTable):
"""CRUD variant of :class:`TransportChainElementTable` with row delete actions."""
activity_distance_value = NumberColumn(
verbose_name=_("Distance"),
)
activity_distance_unit = Column(
verbose_name=_("Distance Unit"),
)
operation__emission_intensity__grade = EmissionGradeColumn()
[docs]
def get_delete_action_url(self, *args, **kwargs) -> str | None:
"""Return the delete URL for the row's transport-chain element."""
return reverse(
"emiflow:transportchainelement_delete", args=[kwargs.get("record").pk]
)
[docs]
class TransportLCCValueTable(LCCValueTable):
"""LCC value table specialised for transport operations."""
[docs]
def get_delete_action_url(self, *args, **kwargs) -> str | None:
"""Return the delete URL for the row's transport LCC value."""
return reverse(
"emiflow:transportlccvalue_delete", args=[kwargs.get("record").pk]
)
[docs]
class TransportOperationTable(Table):
"""List table for :class:`TransportOperation` with linkified reference and emission-grade badge."""
reference = Column(linkify=True)
emission_intensity__grade = EmissionGradeColumn()
[docs]
class TreatmentTable(Table):
"""List table for :class:`Treatment` with a linkified reference column."""
reference = Column(linkify=True)
[docs]
class TreatmentOperationTable(Table):
"""List table for :class:`TreatmentOperation` with linkified reference and emission-grade badge."""
reference = Column(linkify=True)
emission_intensity__grade = EmissionGradeColumn()
[docs]
class TreatmentLineTable(CRUDTable):
"""Base CRUD table for treatment lines, listing material and weight columns.
Subclassed for input, output, and operation-input/output variants;
subclasses override the model and (for editable variants) the delete-URL
resolution.
"""
weight = NumberColumn(
verbose_name=_("Weight"),
with_symbol=True,
)
[docs]
class TreatmentOutputLineTable(TreatmentLineTable):
"""Treatment output lines with an extra allocated-percentage column."""
allocated_fraction = NumberColumn(
with_symbol="%",
quantize=Decimal("1.00"),
transform=lambda value: value * _MULTIPLIER,
)
#: Factor used to convert a 0..1 fraction into a 0..100 percentage when
#: rendering allocation columns.
_MULTIPLIER = Decimal(100)
[docs]
class TreatmentOperationOutputLineTable(TreatmentLineTable):
"""Treatment-operation output lines with allocated percentage and delete actions."""
allocated_fraction = NumberColumn(
with_symbol="%",
quantize=Decimal("1.00"),
transform=lambda value: value * _MULTIPLIER,
)
[docs]
def get_delete_action_url(self, *args, **kwargs) -> str | None:
"""Return the delete URL for the row's output line."""
return reverse(
"emiflow:treatmentoperationoutputline_delete",
args=[kwargs.get("record").pk],
)
[docs]
class TreatmentLCCValueTable(LCCValueTable):
"""LCC value table specialised for treatment operations."""
[docs]
def get_delete_action_url(self, *args, **kwargs) -> str | None:
"""Return the delete URL for the row's treatment LCC value."""
return reverse(
"emiflow:treatmentlccvalue_delete", args=[kwargs.get("record").pk]
)
[docs]
class WarehouseOverviewTable(Table):
"""Aggregated, model-less warehouse overview keyed by material.
Rows come from a service-side aggregation (not the ORM), so the table
declares no ``Meta.model`` and is non-orderable. Numeric columns format
their values through ``format_mass`` so units render alongside the
quantity.
"""
_QUANTIZE = Decimal("1.00")
material = Column(
verbose_name=_("Material"),
)
emission_total = NumberColumn(
verbose_name=_("Emission Total"),
with_symbol=True,
quantize=_QUANTIZE,
transform_value=lambda value: value.format_mass(),
)
emission_operation = NumberColumn(
verbose_name=_("Emission Operation"),
with_symbol=True,
quantize=_QUANTIZE,
transform_value=lambda value: value.format_mass(),
)
weight = NumberColumn(
verbose_name=_("Weight"),
with_symbol=True,
quantize=_QUANTIZE,
transform_value=lambda value: value.format_mass(),
)
average_total = NumberColumn(
verbose_name=_("Average Total"),
with_symbol=True,
quantize=_QUANTIZE,
transform_value=lambda value: value.format_mass(),
)
average_operation = NumberColumn(
verbose_name=_("Average Operation"),
with_symbol=True,
quantize=_QUANTIZE,
transform_value=lambda value: value.format_mass(),
)