Source code for ferrosoft.apps.emiflow.views.reporting

#  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] def get_form_kwargs(self): gen = IdGeneratorPostgres.create(IDSequence.REPORT) return super().get_form_kwargs() | { "initial": { "reference": gen.next_id(), } }
[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] def get_form_kwargs(self): return super().get_form_kwargs() | { "user": LocalUser.load(self.request.user), }
[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] def get_form_kwargs(self): return super().get_form_kwargs() | { "user": LocalUser.load(self.request.user), }
[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, )