Coverage for src / couchers / templates / v2.py: 97%

97 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-07 16:21 +0000

1""" 

2template mailer/push notification formatter v2 

3""" 

4 

5import logging 

6import re 

7from datetime import date, datetime 

8from html import escape 

9from pathlib import Path 

10from typing import Any 

11from zoneinfo import ZoneInfo 

12 

13import phonenumbers 

14from google.protobuf.timestamp_pb2 import Timestamp 

15from jinja2 import Environment, FileSystemLoader, pass_context 

16from jinja2.runtime import Context 

17from markdown_it import MarkdownIt 

18from sqlalchemy.orm import Session 

19 

20from couchers import urls 

21from couchers.config import config 

22from couchers.email import queue_email 

23from couchers.i18n.i18n import localize_string 

24from couchers.models import User 

25from couchers.utils import get_tz_as_text, now, to_aware_datetime 

26 

27logger = logging.getLogger(__name__) 

28 

29template_folder = Path(__file__).parent / ".." / ".." / ".." / "templates" / "v2" 

30 

31loader = FileSystemLoader(template_folder) 

32env = Environment(loader=loader, trim_blocks=True) 

33 

34md = MarkdownIt("zero", {"typographer": True}).enable(["smartquotes", "heading", "hr", "list", "link", "emphasis"]) 

35 

36 

37# Special context values expected by v2 filters 

38CONTEXT_YEAR_KEY = "_year" 

39CONTEXT_TIMEZONE_DISPLAY_KEY = "_timezone_display" 

40CONTEXT_TRANSLATION_LANGUAGE_KEY = "_lang" 

41CONTEXT_PLAINTEXT_KEY = "_plain" 

42 

43 

44def v2esc(value: Any) -> str: 

45 return escape(str(value)) 

46 

47 

48def v2multiline(value: str) -> str: 

49 return "<br />".join(value.splitlines()) 

50 

51 

52def v2sf(value: str) -> str: 

53 return value 

54 

55 

56def v2url(value: str) -> str: 

57 return value 

58 

59 

60def v2phone(value: str) -> str: 

61 return phonenumbers.format_number(phonenumbers.parse(value), phonenumbers.PhoneNumberFormat.INTERNATIONAL) 

62 

63 

64def v2date(value: date | str, user: User) -> str: 

65 # todo: user locale-based date formatting 

66 if isinstance(value, str): 66 ↛ 68line 66 didn't jump to line 68 because the condition on line 66 was always true

67 value = date.fromisoformat(value) 

68 return value.strftime("%A %-d %B %Y") 

69 

70 

71def v2time(value: datetime, user: User) -> str: 

72 tz = ZoneInfo(user.timezone or "Etc/UTC") 

73 return value.astimezone(tz=tz).strftime("%-I:%M %p (%H:%M)") 

74 

75 

76def v2timestamp(value: Timestamp, user: User) -> str: 

77 tz = ZoneInfo(user.timezone or "Etc/UTC") 

78 return to_aware_datetime(value).astimezone(tz=tz).strftime("%A %-d %B %Y at %-I:%M %p (%H:%M)") 

79 

80 

81def v2avatar(user: Any) -> str: 

82 if not user.avatar_thumbnail_url: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true

83 return urls.icon_url() 

84 return user.avatar_thumbnail_url # type: ignore[no-any-return] 

85 

86 

87def v2quote(value: str) -> str: 

88 """ 

89 Multiline quote, use in place of markdown in plaintext emails 

90 """ 

91 return "\n> ".join([""] + value.splitlines()) 

92 

93 

94def v2markdown(value: str) -> str: 

95 return md.render(value) # type: ignore[no-any-return] 

96 

97 

98def replace_tag(match: re.Match[str]) -> str: 

99 tag = match.group(1) 

100 inner_text = match.group(2) 

101 if tag.lower() == "a": 

102 # <a href="url">text</a> -> <text> 

103 return f"<{inner_text}>" 

104 else: 

105 # <b>hello</b> -> hello 

106 return inner_text 

107 

108 

109@pass_context 

110def v2translate(context: Context, key: str, **kwargs: Any) -> str: 

111 """ 

112 Jinja2 filter to translate a string key with substitutions. 

113 

114 Usage in template: 

115 {{ "greeting_key"|v2translate(name=user.name) }} 

116 """ 

117 

118 lang: str = context[CONTEXT_TRANSLATION_LANGUAGE_KEY] 

119 

120 # Prevent html injection 

121 escaped_substitutions = {k: escape(str(v)) for k, v in kwargs.items()} 

122 

123 translated = localize_string(lang, key, substitutions=escaped_substitutions) 

124 

125 # Translations may include simple formatting HTML like <b> or <a>, 

126 # but those should not appear in plain text emails. 

127 if context.parent.get(CONTEXT_PLAINTEXT_KEY) == True: 

128 # Doesn't support nesting, but should be sufficient for our needs 

129 translated = re.sub(r"<(\w+).*?>(.*?)</\1>", replace_tag, translated) 

130 translated = re.sub(r"<br\s*/?>", "\n", translated) 

131 

132 else: 

133 # HTML support, email flavored 

134 # mjml rendering converts <br /> to <br>, so prefer that form. 

135 translated = translated.replace("\n", "<br>") 

136 

137 return translated 

138 

139 

140def add_filters(env: Environment) -> None: 

141 env.filters["v2esc"] = v2esc 

142 env.filters["v2multiline"] = v2multiline 

143 env.filters["v2sf"] = v2sf 

144 env.filters["v2url"] = v2url 

145 env.filters["v2phone"] = v2phone 

146 env.filters["v2date"] = v2date 

147 env.filters["v2time"] = v2time 

148 env.filters["v2timestamp"] = v2timestamp 

149 env.filters["v2avatar"] = v2avatar 

150 env.filters["v2quote"] = v2quote 

151 env.filters["v2markdown"] = v2markdown 

152 env.filters["v2translate"] = v2translate 

153 

154 

155add_filters(env) 

156 

157 

158def send_simple_pretty_email( 

159 session: Session, recipient: str, subject: str, template_name: str, template_args: dict[str, Any] 

160) -> None: 

161 """ 

162 This is a simplified version of couchers.notifications.background._send_email_notification 

163 

164 It's for the few security emails where we don't have a user to email but send directly to an email address. 

165 """ 

166 template_args[CONTEXT_TRANSLATION_LANGUAGE_KEY] = "en" # Not yet localizable 

167 template_args[CONTEXT_YEAR_KEY] = now().year 

168 template_args[CONTEXT_TIMEZONE_DISPLAY_KEY] = get_tz_as_text("Etc/UTC") 

169 template_args["footer_email_is_critical"] = True # Results in no unsubscribe footer. 

170 

171 plain_tmplt = (template_folder / f"{template_name}.txt").read_text() 

172 plain_tmplt_footer = (template_folder / "_footer.txt").read_text() 

173 plain = env.from_string(plain_tmplt + plain_tmplt_footer).render(template_args) 

174 

175 html_tmplt = (template_folder / "generated_html" / f"{template_name}.html").read_text() 

176 html = env.from_string(html_tmplt).render(template_args) 

177 

178 queue_email( 

179 session, 

180 sender_name=config["NOTIFICATION_EMAIL_SENDER"], 

181 sender_email=config["NOTIFICATION_EMAIL_ADDRESS"], 

182 recipient=recipient, 

183 subject=config["NOTIFICATION_PREFIX"] + subject, 

184 plain=plain, 

185 html=html, 

186 source_data=config["VERSION"] + f"/{template_name}", 

187 )