# Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
"""Models for emission reports and the templates that render them.
A :class:`Report` captures the user's selection (type, filter query
string, proportional-emission method); a :class:`ReportTemplate`
captures the visual chrome (logo, preamble, epilogue) reused across
generated documents. Logos are inlined as base64 so the rendered HTML
can be turned into PDF by WeasyPrint without external fetches.
"""
from base64 import b64encode
from functools import cached_property
from attrs import define
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from ferrosoft.apps.ferrobase.models import UUIDModel, StoredFile, CompanySite
[docs]
class ProportionalEmission(models.IntegerChoices):
"""Which transport leg's weight overrides the others when computing PCF.
When enabled, the chosen leg's weight is treated as authoritative and
other lifecycle categories' emissions are scaled down proportionally.
"""
DISABLED = 0, _("Disabled")
INCOMING_TRANSPORT = 2, _("Incoming Transport")
OUTGOING_TRANSPORT = 1, _("Outgoing Transport")
[docs]
class ReportType(models.IntegerChoices):
"""The supported report formats."""
PCF = 1, _("Product Carbon Footprint")
TRANSPORT = 2, _("Transport Emissions (ISO 14083)")
WAREHOUSE = 3, _("Warehouse Overview")
[docs]
class AspectRatio(models.IntegerChoices):
"""Preset width:height ratios available for logo sizing."""
AUTOMATIC = 0, _("Automatic")
RATIO_16_9 = 1, "16:9"
RATIO_16_10 = 2, "16:10"
RATIO_1_1 = 3, "1:1"
RATIO_1_2 = 4, "1:2"
[docs]
class PhotoOrientation(models.IntegerChoices):
"""Orientation of a logo image (landscape or portrait)."""
LANDSCAPE = 1, _("Landscape")
PORTRAIT = 2, _("Portrait")
[docs]
@define
class Base64Image:
"""An image inlined as a base64 string for embedding in rendered HTML/PDF."""
media_type: str
"""Media/MIME type"""
content: str
"""Image data encoded as base64."""
[docs]
@classmethod
def from_stored_file(cls, file: StoredFile) -> "Base64Image | None":
"""Read ``file`` and return its content base64-encoded.
Returns ``None`` when the underlying storage file is missing,
which can happen if the storage backend has been cleared.
"""
try:
file_content = file.content.read()
return Base64Image(
media_type=file.media_type,
content=b64encode(file_content).decode(),
)
except FileNotFoundError:
return None
[docs]
class ReportTemplate(UUIDModel):
"""Reusable visual template applied when rendering a :class:`Report`.
Carries the document title, preamble, epilogue, sender address, and a
company logo (with explicit sizing/orientation hints because PDF
renderers can't infer them reliably).
"""
class Meta:
verbose_name = _("Report Template")
verbose_name_plural = _("Report Templates")
indexes = [models.Index(fields=["name"])]
name = models.CharField(
verbose_name=_("Name"),
)
document_title = models.CharField(
blank=True,
default="",
verbose_name=_("Document Title"),
)
document_preamble = models.TextField(
blank=True,
default="",
verbose_name=_("Preamble"),
)
document_epilogue = models.TextField(
blank=True,
default="",
verbose_name=_("Epilogue"),
)
document_sender = models.ForeignKey(
CompanySite,
blank=True,
null=True,
on_delete=models.RESTRICT,
related_name="+",
verbose_name=_("Sender Address"),
)
company_logo = models.ForeignKey(
StoredFile,
null=True,
on_delete=models.RESTRICT,
related_name="+",
verbose_name=_("Company Logo"),
)
company_logo_width = models.PositiveIntegerField(
blank=True,
default=150,
verbose_name=_("Company Logo Width"),
help_text=_(
"Specify width in pixels. Height is set according to aspect ratio."
),
)
company_logo_aspect_ratio = models.IntegerField(
choices=AspectRatio.choices,
default=AspectRatio.AUTOMATIC,
help_text=_(
"Aspect ratios refer to landscape orientation, swap numbers for portrait. With automatic ratio, height is scaled proportionally to given width."
),
verbose_name=_("Company Logo Aspect Ratio"),
)
company_logo_orientation = models.IntegerField(
choices=PhotoOrientation.choices,
default=PhotoOrientation.LANDSCAPE,
verbose_name=_("Company Logo Orientation"),
)
def __str__(self):
return str(self.name)
[docs]
def get_absolute_url(self):
return reverse("emiflow:reporttemplate_update", args=[self.pk])
@property
def company_logo_style(self) -> str:
"""Return an inline CSS ``style`` value sizing the company logo."""
styles = {
"width": f"{self.company_logo_width}px",
}
aspect_ratio = self._calc_aspect_ratio()
if aspect_ratio is not None:
styles["aspect-ratio"] = str(aspect_ratio)
return "; ".join([f"{key}: {value}" for key, value in styles.items()])
[docs]
@cached_property
def company_logo_base64(self) -> Base64Image | None:
"""Return the company logo as a :class:`Base64Image` or ``None`` when unset."""
if self.company_logo is None:
return None
return Base64Image.from_stored_file(self.company_logo)
def _calc_aspect_ratio(self) -> float | None:
"""Compute the numeric aspect ratio (width/height) for the configured preset."""
width, height = 1, 1
match AspectRatio(self.company_logo_aspect_ratio):
case AspectRatio.AUTOMATIC:
return None
case AspectRatio.RATIO_1_1:
width, height = 1, 1
case AspectRatio.RATIO_1_2:
width, height = 1, 2
case AspectRatio.RATIO_16_9:
width, height = 16, 9
case AspectRatio.RATIO_16_10:
width, height = 16, 10
case _:
return None
if self.company_logo_orientation == PhotoOrientation.LANDSCAPE.value:
return width / height
return height / width
[docs]
class Report(UUIDModel):
"""A saved emission report (selection criteria plus presentation options).
Reports persist the filter query string they were generated from so
the same set of booking records can be reproduced later through
:meth:`~ferrosoft.apps.emiflow.filter.EmissionBookingRecordFilter.get_user_filtered`.
"""
class Meta:
verbose_name = _("Report")
verbose_name_plural = _("Reports")
indexes = [
models.Index(fields=["type"]),
models.Index(fields=["reference"]),
models.Index(fields=["name"]),
models.Index(fields=["creation_time"]),
]
reference = models.CharField(
verbose_name=_("Reference"),
)
type = models.IntegerField(
choices=ReportType.choices,
verbose_name=_("Type"),
)
name = models.CharField(
verbose_name=_("Name"),
)
creation_time = models.DateTimeField(
verbose_name=_("Creation Time"),
auto_now_add=True,
)
filters = models.CharField(
verbose_name=_("Filters"),
default="",
blank=True,
)
"""Filter query string for use by FilterSet class"""
proportional_emissions = models.IntegerField(
choices=ProportionalEmission.choices,
default=ProportionalEmission.DISABLED,
help_text=_(
"Use weight of selected lifecycle category for other lifecycle categories with emission proportionally reduced"
),
verbose_name=_("Proportional Emissions"),
)
def __str__(self):
return str(self.reference)
[docs]
def get_absolute_url(self):
return reverse("emiflow:report_update", args=[self.pk])