Coverage for functions \ flipdare \ task \ report \ core \ table_report.py: 84%
58 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
1#!/usr/bin/env python
2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved.
3#
4# This file is part of Flipdare's proprietary software and contains
5# confidential and copyrighted material. Unauthorised copying,
6# modification, distribution, or use of this file is strictly
7# prohibited without prior written permission from Flipdare Pty Ltd.
8#
9# This software includes third-party components licensed under MIT,
10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details.
11#
14from abc import ABC, abstractmethod
15from collections.abc import Callable, Mapping, Sequence
16from typing import Any, override
17from flipdare.task.report.core._admin_report import AdminReport
18from flipdare.backend.app_logger import AppLogger
19from flipdare.app_log import LOG
20from flipdare.app_types import ReportListType
21from flipdare.constants import EMAIL_MAX_LINE_LENGTH, IS_DEBUG
22from flipdare.mailer.core.email_composer import EmailComposer
23from flipdare.mailer.admin_mailer import AdminMailer
24from flipdare.generated.shared.backend.app_job_type import AppJobType
27class TableReport[TSchema: Mapping[str, Any]](AdminReport, ABC):
28 __slots__ = (
29 "_process_fn",
30 "_schema_cls",
31 )
33 def __init__(
34 self,
35 job_type: AppJobType,
36 schema_cls: type[TSchema],
37 process_fn: Callable[..., TSchema],
38 app_logger: AppLogger,
39 mailer: AdminMailer,
40 ) -> None:
41 super().__init__(
42 job_type=job_type,
43 app_logger=app_logger,
44 mailer=mailer,
45 )
46 self._schema_cls = schema_cls
47 self._process_fn = process_fn
49 @abstractmethod
50 def table_data(self) -> Sequence[Any] | None: ...
52 @property
53 def keys(self) -> list[str]:
54 return list(self._schema_cls.__annotations__.keys())
56 @property
57 def headers(self) -> list[str]:
58 return [key.replace("_", " ").title() for key in self.keys]
60 @override
61 def compose(self) -> EmailComposer | None:
62 report_data: ReportListType = []
64 try:
65 results = self.table_data()
66 if results is None:
67 msg = f"Report {self.job_type.value} returned no data"
68 self.add_error(msg)
69 return None
71 total_ct = len(results)
72 if IS_DEBUG:
73 LOG().debug(f"Report {self.job_type.value} generated {total_ct} results")
75 for result in results:
76 try:
77 entry = self._process_fn(result)
78 raw_keys = self.keys
79 formatted_values = [str(entry.get(k, "")) for k in raw_keys]
80 report_data.append(formatted_values)
81 except Exception as ex:
82 # since we send errors we need to ensure
83 # 500, b'Line too long (see RFC5321 4.5.3.1.6)'):
85 if hasattr(result, "doc_id"):
86 identifier = getattr(result, "doc_id", "unknown")
87 else:
88 identifier = str(result)
90 error_msg = f"Failed to generate report entry for doc {identifier}: {ex}"
91 error_msg = (
92 error_msg[:EMAIL_MAX_LINE_LENGTH] + "..."
93 if len(error_msg) > EMAIL_MAX_LINE_LENGTH
94 else error_msg
95 )
96 self.add_error(error_msg)
98 except Exception as ex:
99 msg = f"Failed to generate report data: {ex}"
100 self.add_error(msg)
101 return None
103 try:
104 return EmailComposer.table(
105 data=report_data,
106 headers=self.headers,
107 priority=self.priority,
108 )
109 except Exception as ex:
110 msg = f"Failed to generate report for {self._debug_label}\tError: {ex}"
111 self.add_error(msg)
112 return None