Coverage for functions \ flipdare \ mailer \ core \ email_composer.py: 86%
80 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#
13from __future__ import annotations
15from dataclasses import dataclass, field
16from flipdare.app_log import LOG
17from flipdare.app_types import ReportListType
18from flipdare.mailer.core.base_email_formatter import BaseEmailFormatter
19from flipdare.mailer.core.gallery_email_formatter import GalleryEmailFormatter
20from flipdare.mailer.core.table_email_formatter import ExtraEmailInfo, TableEmailFormatter
21from flipdare.mailer.email_image import EmailImage
22from flipdare.constants import IS_TRACE
23from flipdare.error.data_load_error import DataLoadError
24from flipdare.generated.shared.backend.app_report_priority import AppReportPriority
26__all__ = [
27 "EmailComposer",
28 "ComposedEmailResult",
29]
32@dataclass(frozen=True)
33class ComposedEmailResult:
34 html: str
35 text: str
36 priority: AppReportPriority
37 images: list[EmailImage] = field(default_factory=list)
38 error: DataLoadError | None = None
40 @property
41 def is_error(self) -> bool:
42 return self.error is not None
45class EmailComposer:
46 __slots__ = (
47 "_extra",
48 "_formatter",
49 "_priority",
50 )
52 _formatter: BaseEmailFormatter
53 _priority: AppReportPriority
55 def __init__(
56 self,
57 *,
58 _internal: bool = False,
59 formatter: BaseEmailFormatter,
60 priority: AppReportPriority = AppReportPriority.MEDIUM,
61 ) -> None:
62 if not _internal:
63 msg = f"Use {self.__class__.__name__}.table() or .gallery() or .custom() instead"
64 raise RuntimeError(msg)
66 self._priority = priority
67 self._formatter = formatter
69 @classmethod
70 def table(
71 cls,
72 data: ReportListType,
73 headers: list[str],
74 priority: AppReportPriority = AppReportPriority.MEDIUM,
75 extra: ExtraEmailInfo | None = None,
76 ) -> EmailComposer:
77 # NOTE: make headers required , otherwise pandas will auto assign 0,1,2..
78 return cls(
79 _internal=True,
80 formatter=TableEmailFormatter(
81 headers=headers,
82 data=data,
83 extra=extra,
84 ),
85 priority=priority,
86 )
88 @classmethod
89 def gallery(
90 cls,
91 data: ReportListType,
92 headers: list[str],
93 images: list[EmailImage],
94 images_per_row: int = 3,
95 priority: AppReportPriority = AppReportPriority.MEDIUM,
96 ) -> EmailComposer:
97 # NOTE: make headers required , otherwise pandas will auto assign 0,1,2..
98 return cls(
99 _internal=True,
100 formatter=GalleryEmailFormatter(
101 headers=headers,
102 data=data,
103 images=images,
104 images_per_row=images_per_row,
105 ),
106 priority=priority,
107 )
109 @property
110 def priority(self) -> AppReportPriority:
111 return self._priority
113 @property
114 def parser(self) -> BaseEmailFormatter:
115 return self._formatter
117 @property
118 def data(self) -> ReportListType:
119 return self.parser.data
121 @property
122 def headers(self) -> list[str]:
123 return self.parser.headers
125 def load(self) -> ComposedEmailResult:
126 from flipdare.error.message_format import ValidationErrorMsgFormat
128 html: str = ""
129 text: str = ""
130 error: Exception | None = None
132 try:
133 html = self.as_html() or ""
134 text = self.as_text() or ""
135 except Exception as e:
136 msg = f"Failed to generate HTML report: {e!s}"
137 LOG().error(msg)
138 error = e
140 images = self.parser.images if isinstance(self.parser, GalleryEmailFormatter) else []
142 if error is None:
143 if IS_TRACE:
144 msg = (
145 f"Successfully generated HTML report with TEXT={len(html)} chars, "
146 f"HTML={len(text)} chars, and {len(images)} images."
147 )
148 LOG().trace(msg)
150 return ComposedEmailResult(
151 html=html,
152 text=text,
153 priority=self.priority,
154 images=images,
155 error=None,
156 )
157 else:
158 formatter = ValidationErrorMsgFormat(
159 class_type=self.__class__,
160 error=error,
161 )
163 LOG().error(str(formatter))
164 validation_error = DataLoadError.model(
165 class_name=formatter.class_name,
166 missing_code=formatter.user_error_code,
167 error=[str(error)],
168 )
169 return ComposedEmailResult(
170 html=html,
171 text=text,
172 priority=self.priority,
173 images=images,
174 error=validation_error,
175 )
177 def as_html(self) -> str | None:
178 if len(self.data) == 0:
179 return None
181 return self.parser.tabulate_html()
183 def as_text(self) -> str | None:
184 if len(self.data) == 0:
185 return None
187 return self.parser.tabulate_text()