Source code for ferrosoft.apps.emiflow.tables

#  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 %. """
[docs] class Meta: model = TreatmentOperationOutputLine orderable = False fields = [ "material", "weight", "allocated_fraction", ]
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."""
[docs] class Meta: model = EmissionBookingRecord fields = [ "creation_time", "booking_date", "lifecycle_category_name", "entity_reference", "emission_total_value", "emission_operation_value", "emission_unit_symbol", "weight_value", "weight_unit_symbol", "material_code", "material_name", "incoming_partner_name", "outgoing_partner_name", ]
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] class Meta(CRUDTable.Meta): model = EmissionFactor fields = [ "actions", "category", "value", "unit", ]
[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."""
[docs] class Meta: model = Emitter fields = [ "name", "epitype", "vehicle_type", "energy_carrier", "catalog", "emission_intensity__grade", ]
name = Column(linkify=True) emission_intensity__grade = EmissionGradeColumn()
[docs] class EnergyCarrierTable(Table): """List table for :class:`EnergyCarrier` showing composition, heating value, and density."""
[docs] class Meta: model = EnergyCarrier fields = [ "name", "composition", "application", "lower_heating", "density", "catalog", ]
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. """
[docs] class Meta(CRUDTable.Meta): model = FreightItem fields = [ "actions", "material", "weight_value", "weight_unit", ]
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. """
[docs] class Meta(CRUDTable.Meta): model = LCCValue orderable = False fields = [ "actions", "lifecycle_category", "operation_emission", "total_emission", ]
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."""
[docs] class Meta: model = Report fields = [ "reference", "name", "type", "proportional_emissions", "creation_time", ]
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`. """
[docs] class Meta: model = TransportChain fields = [ "reference", "booking_date", "status", "transport_type", "incoterms", "consignor", "consignee", "simulation", ]
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."""
[docs] class Meta: model = TransportChainElement fields = [ "position", "epitype", "operation", "operation__emission_intensity__grade", "operation__emitter", "operation__energy_carrier", "activity_distance_value", "activity_distance_unit", "activity_distance_type", ]
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."""
[docs] class Meta(CRUDTable.Meta): model = TransportChainElement orderable = False fields = [ "actions", "position", "epitype", "operation", "operation__emission_intensity__grade", "operation__emitter", "operation__energy_carrier", "activity_distance_value", "activity_distance_unit", "activity_distance_type", ]
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] class Meta(LCCValueTable.Meta): model = TransportLCCValue
[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."""
[docs] class Meta: model = TransportOperation fields = [ "reference", "name", "activity_type", "emitter", "energy_carrier", "emission_intensity__grade", ]
reference = Column(linkify=True) emission_intensity__grade = EmissionGradeColumn()
[docs] class TreatmentTable(Table): """List table for :class:`Treatment` with a linkified reference column."""
[docs] class Meta: model = Treatment fields = [ "reference", "status", "booking_date", "operation__name", "operation__emitter", "operation__energy_carrier", ]
reference = Column(linkify=True)
[docs] class TreatmentOperationTable(Table): """List table for :class:`TreatmentOperation` with linkified reference and emission-grade badge."""
[docs] class Meta: model = TreatmentOperation fields = [ "reference", "status", "name", "emitter", "energy_carrier", "emission_intensity__grade", ]
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. """
[docs] class Meta: model = TreatmentLine orderable = False fields = [ "material", "weight", ] template_name = CRUDTable.Meta.template_name
weight = NumberColumn( verbose_name=_("Weight"), with_symbol=True, )
[docs] class TreatmentInputLineTable(TreatmentLineTable): """Treatment input lines (the materials consumed by a treatment)."""
[docs] class Meta: model = TreatmentInputLine orderable = TreatmentLineTable.Meta.orderable fields = TreatmentLineTable.Meta.fields template_name = TreatmentLineTable.Meta.template_name
[docs] class TreatmentOutputLineTable(TreatmentLineTable): """Treatment output lines with an extra allocated-percentage column."""
[docs] class Meta: model = TreatmentOutputLine orderable = TreatmentLineTable.Meta.orderable fields = TreatmentLineTable.Meta.fields template_name = TreatmentLineTable.Meta.template_name
allocated_fraction = NumberColumn( with_symbol="%", quantize=Decimal("1.00"), transform=lambda value: value * _MULTIPLIER, )
[docs] class TreatmentOperationInputLineTable(TreatmentLineTable): """Treatment-operation input lines with delete actions."""
[docs] class Meta: model = TreatmentOperationInputLine orderable = TreatmentLineTable.Meta.orderable fields = TreatmentLineTable.Meta.fields template_name = TreatmentLineTable.Meta.template_name
[docs] def get_delete_action_url(self, *args, **kwargs) -> str | None: """Return the delete URL for the row's input line.""" return reverse( "emiflow:treatmentoperationinputline_delete", args=[kwargs.get("record").pk], )
#: 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."""
[docs] class Meta: model = TreatmentOperationOutputLine orderable = TreatmentLineTable.Meta.orderable fields = TreatmentLineTable.Meta.fields + ["allocated_fraction"] template_name = TreatmentLineTable.Meta.template_name
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] class Meta(LCCValueTable.Meta): model = TreatmentLCCValue
[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")
[docs] class Meta: orderable = False
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(), )