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

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# 

12 

13from __future__ import annotations 

14 

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 

25 

26__all__ = [ 

27 "EmailComposer", 

28 "ComposedEmailResult", 

29] 

30 

31 

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 

39 

40 @property 

41 def is_error(self) -> bool: 

42 return self.error is not None 

43 

44 

45class EmailComposer: 

46 __slots__ = ( 

47 "_extra", 

48 "_formatter", 

49 "_priority", 

50 ) 

51 

52 _formatter: BaseEmailFormatter 

53 _priority: AppReportPriority 

54 

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) 

65 

66 self._priority = priority 

67 self._formatter = formatter 

68 

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 ) 

87 

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 ) 

108 

109 @property 

110 def priority(self) -> AppReportPriority: 

111 return self._priority 

112 

113 @property 

114 def parser(self) -> BaseEmailFormatter: 

115 return self._formatter 

116 

117 @property 

118 def data(self) -> ReportListType: 

119 return self.parser.data 

120 

121 @property 

122 def headers(self) -> list[str]: 

123 return self.parser.headers 

124 

125 def load(self) -> ComposedEmailResult: 

126 from flipdare.error.message_format import ValidationErrorMsgFormat 

127 

128 html: str = "" 

129 text: str = "" 

130 error: Exception | None = None 

131 

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 

139 

140 images = self.parser.images if isinstance(self.parser, GalleryEmailFormatter) else [] 

141 

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) 

149 

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 ) 

162 

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 ) 

176 

177 def as_html(self) -> str | None: 

178 if len(self.data) == 0: 

179 return None 

180 

181 return self.parser.tabulate_html() 

182 

183 def as_text(self) -> str | None: 

184 if len(self.data) == 0: 

185 return None 

186 

187 return self.parser.tabulate_text()