# Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
from contextlib import suppress
from pathlib import PurePath
from uuid import UUID
from asgiref.sync import sync_to_async
from django.contrib.auth.decorators import login_required
from django.core.files.base import ContentFile
from django.http import HttpRequest, QueryDict, HttpResponse
from django.shortcuts import get_object_or_404, aget_object_or_404
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
from ferrosoft.apps.emiflow.filter import ReportFilter, EmissionBookingRecordFilter
from ferrosoft.apps.emiflow.forms.reporting import (
ReportForm,
ReportOutputSelectionForm,
ReportTemplateForm,
ReportTemplateSelectionForm,
)
from ferrosoft.apps.emiflow.models import (
Report,
ReportType,
TransportChain,
Treatment,
EmissionBookingRecord,
ReportTemplate,
)
from ferrosoft.apps.emiflow.services.reporting import (
DocumentGenerator,
DocumentGeneratorRegistry,
generate_report_document,
)
from ferrosoft.apps.emiflow.tables import ReportTable, EmissionBookingRecordTable
from ferrosoft.apps.ferrobase.auth.roles import Permissions, Verb
from ferrosoft.apps.ferrobase.middleware import TenantMiddleware
from ferrosoft.apps.ferrobase.models import IDSequence, LocalUser, StoredFile, Tenant
from ferrosoft.apps.ferrobase.services.idgenerator import IdGeneratorPostgres
from ferrosoft.apps.ferrobase.tenant.context import active_tenant
from ferrosoft.apps.ferrobase.views import render_ajax_result, FeatureRequiredMixin
from ferrosoft.apps.ferrobase.views.generic import (
CreateView,
TableView,
UpdateView,
DeleteView,
ListView,
model_crud_routes,
)
from ferrosoft.apps.reporting.models import Report as UpstreamReport
from ferrosoft.apps.reporting.services import prepare_report, Params
from ferrosoft.apps.reporting.views import report_generator_modal_content
[docs]
class ReportCreate(CreateView):
form_class = ReportForm
help_topic = "new_report"
model = Report
[docs]
class ReportRead(FeatureRequiredMixin, TableView):
feature_required = "reporting"
filterset_class = ReportFilter
help_topic = "reports"
model = Report
ordering = "-reference"
table_class = ReportTable
template_name = "emiflow/report/index.html"
[docs]
class ReportUpdate(FeatureRequiredMixin, UpdateView):
feature_required = "reporting"
form_class = ReportForm
help_topic = "new_report"
model = Report
template_name = "emiflow/report/update.html"
[docs]
def get_context_data(self, **kwargs):
booking_records, *rest = EmissionBookingRecordFilter.get_user_filtered(
self.object.filters, request=self.request
)
return super().get_context_data(**kwargs) | {
"booking_records_amount": len(booking_records),
}
[docs]
class ReportDelete(FeatureRequiredMixin, DeleteView):
feature_required = "reporting"
help_topic = "reports"
model = Report
report_urls = model_crud_routes(
Report,
create_view=ReportCreate,
read_view=ReportRead,
update_view=ReportUpdate,
delete_view=ReportDelete,
)
[docs]
class ReportSelectionView(FeatureRequiredMixin, TableView):
add_create_action = False
enable_filter_display = False
feature_required = "reporting"
filterset_class = EmissionBookingRecordFilter
help_topic = "report_selection"
model = EmissionBookingRecord
ordering = "-booking_date"
table_class = EmissionBookingRecordTable
template_name = "emiflow/report/selection.html"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._report = None
self._records_filtered = None
self._saved_filterset = None
[docs]
def get(self, request, *args, **kwargs):
if self._report is None:
self._report = self.get_report()
booking_records, user_filter = EmissionBookingRecordFilter.get_user_filtered(
self._report.filters, request=self.request
)
self._records_filtered = booking_records
self._saved_filterset = user_filter
return super().get(request, *args, **kwargs)
[docs]
def get_report(self) -> Report:
return get_object_or_404(Report, pk=self.request.resolver_match.kwargs["pk"])
[docs]
def get_filterset(self, filterset_class):
return self._saved_filterset
[docs]
def get_queryset(self):
# Query set is constrained by filters saved in Report.
return self._records_filtered
[docs]
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"object": self._report,
}
[docs]
def post(self, request, *args, **kwargs):
# These filters are saved in Report for later filtering of emission records.
filters = QueryDict(request.POST.urlencode(), mutable=True)
# CSRF token must not be saved.
filters.pop("csrfmiddlewaretoken")
self._report = self.get_report()
self._report.filters = filters.urlencode()
self._report.save(update_fields=["filters"])
return self.get(request, *args, **kwargs)
[docs]
@login_required
async def report_document(request, report_pk):
"""Ajax view displaying report document.
Contents of report document is specific to report type.
Therefore, rendering of document is offloaded to a view
function defined in DOCUMENT_VIEWS.
"""
report = await aget_object_or_404(Report, pk=report_pk)
booking_records, *rest = await EmissionBookingRecordFilter.aget_user_filtered(
report.filters
)
generator = DocumentGeneratorRegistry.get_generator(
ReportType(report.type),
booking_records=booking_records,
report=report,
)
# Tenant must be passed explicitly to report generator function because it
# runs in a separate thread where current tenant is not activated.
tenant, *rest = await TenantMiddleware.adetermine_tenant(
request, TenantMiddleware.choose_url(request)
)
# Run actual report generator function in separate thread to avoid starving
# workers if the generator takes a while.
return await run_document_generator(request, generator, tenant)
[docs]
@sync_to_async(thread_sensitive=False)
def run_document_generator(
request: HttpRequest, generator: DocumentGenerator, tenant: Tenant
) -> HttpResponse:
# Tenant has to be forced as this function runs in a separate thread.
with active_tenant(tenant):
template = None
if request.method == "POST":
template_form = ReportTemplateSelectionForm(request.POST)
if template_form.is_valid():
template = template_form.cleaned_data["template"]
else:
template_form = ReportTemplateSelectionForm()
outputs = generator.generate(template)
if len(outputs) == 0:
return render_ajax_result(
request,
success=True,
template="emiflow/report/document/failure.html",
message=_(
"Please select emission booking records to display the report document."
),
)
reports = generator.prepare_report_models(outputs, LocalUser.load(request.user))
content = outputs[0].content
return render_ajax_result(
request,
content=content,
content_warning=_enable_content_warning(content),
download_url=reverse("emiflow:report_download", args=[reports[0].pk]),
message=_("Document Created"),
report=generator.report,
success=True,
template="emiflow/report/document/view.html",
template_form=template_form,
output_form=ReportOutputSelectionForm(reports=zip(outputs, reports)),
)
def _enable_content_warning(content: str) -> bool:
# Large amounts of HTML may result in performance degradation client-side.
# 2 MiB limit is arbitrary, may need to be adjusted.
return len(content) > 2_097_152
[docs]
@login_required
@require_POST
def report_document_output(request: HttpRequest):
report_id = request.POST.get("report", "")
with suppress(ValueError):
report_id = UUID(report_id)
if not report_id:
return render_ajax_result(request, success=False, message=_("Bad Request"))
# Owner of the report must be equal to currently logged-in user.
user = LocalUser.load(request.user)
report = UpstreamReport.objects.filter(pk=report_id, document__owner=user).first()
if report is None:
return render_ajax_result(request, success=False, message=_("Report Not Found"))
content = report.document.content.read().decode()
return render_ajax_result(
request,
success=True,
template="emiflow/report/document/view_output.html",
content=content,
content_warning=_enable_content_warning(content),
download_url=reverse("emiflow:report_download", args=[report.pk]),
)
_HEADER_TEMPLATE = """<!DOCTYPE html>
<head><meta charset="utf-8"><title>%s</title></head>
<body>"""
_BODY_CLOSE = b"""</body></html>"""
[docs]
@login_required
def report_download(request, file_pk: str) -> HttpResponse:
user = LocalUser.load(request.user)
html_report = UpstreamReport.objects.filter(
pk=file_pk, document__owner=user
).first()
if html_report is None:
return render_ajax_result(request, success=False, message=_("Bad Request"))
# HTML report stores the HTML content. Download means creating a PDF document
# from the HTML document. The HTML content is copied to a new UpstreamReport.
# The content cannot be replaced in the same file, as the user might switch
# between document outputs and would get served PDF blob when expecting HTML.
document: StoredFile = html_report.document
html_file_name = PurePath(document.name)
report_name = html_file_name.stem
file_name = str(html_file_name.with_suffix(".pdf"))
pdf_report = prepare_report(
generate_report_document,
params=Params(owner=user, file_name=file_name),
)
# Reports created by report_document view are not a standard HTML structure,
# due to shadow DOM not requiring it. However, for PDF export, <head> and
# most importantly <meta charset> must be included.
header = _HEADER_TEMPLATE % report_name
content = header.encode() + document.content.read() + _BODY_CLOSE
pdf_report.document.content.save(file_name, ContentFile(content))
return report_generator_modal_content(request, pdf_report)
[docs]
class ReportTemplateIndex(FeatureRequiredMixin, ListView):
feature_required = "report_ci"
help_topic = "report_templates"
model = ReportTemplate
ordering = ["name"]
[docs]
class ReportTemplateCreate(FeatureRequiredMixin, CreateView):
feature_required = "report_ci"
form_class = ReportTemplateForm
form_template = "emiflow/report_template/form.html"
help_topic = "report_templates"
model = ReportTemplate
multipart_form = True
template_name = "emiflow/report_template/create.html"
[docs]
class ReportTemplateUpdate(FeatureRequiredMixin, UpdateView):
feature_required = "report_ci"
form_class = ReportTemplateForm
form_template = "emiflow/report_template/form.html"
help_topic = "report_templates"
model = ReportTemplate
multipart_form = True
template_name = "emiflow/report_template/update.html"
[docs]
class ReportTemplateDelete(FeatureRequiredMixin, DeleteView):
feature_required = "report_ci"
help_topic = "report_templates"
model = ReportTemplate
report_template_urls = model_crud_routes(
ReportTemplate,
create_view=ReportTemplateCreate,
read_view=ReportTemplateIndex,
update_view=ReportTemplateUpdate,
delete_view=ReportTemplateDelete,
)