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

93 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-13 12:05 +0000

1""" 

2template mailer/push notification formatter v2 

3""" 

4 

5import logging 

6import re 

7from dataclasses import dataclass 

8from datetime import date, datetime 

9from functools import lru_cache 

10from html import escape 

11from pathlib import Path 

12from typing import Any, ClassVar 

13from zoneinfo import ZoneInfo 

14 

15from google.protobuf.timestamp_pb2 import Timestamp 

16from jinja2 import Environment, FileSystemLoader, pass_context 

17from jinja2.runtime import Context as JinjaContext 

18from markdown_it import MarkdownIt 

19 

20from couchers.i18n.localize import format_phone_number, localize_date, localize_datetime, localize_string, localize_time 

21 

22logger = logging.getLogger(__name__) 

23 

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

25 

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

27 

28 

29# Special context values expected by v2 filters 

30CONTEXT_YEAR_KEY = "_year" 

31 

32 

33@dataclass(frozen=True, slots=True, kw_only=True) 

34class Context: 

35 """Context available to filter functions during templating.""" 

36 

37 KEY: ClassVar[str] = "_filter_context" 

38 

39 timezone: ZoneInfo 

40 """The timezone to use when formatting times.""" 

41 

42 locale: str 

43 """The locale to use when localizing strings or formatting times.""" 

44 

45 plaintext: bool 

46 """If true, strips html tags from localized strings.""" 

47 

48 @staticmethod 

49 def from_jinja(jinja_context: JinjaContext) -> Context: 

50 context: Context = jinja_context[Context.KEY] 

51 return context 

52 

53 

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

55 return escape(str(value)) 

56 

57 

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

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

60 

61 

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

63 return value 

64 

65 

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

67 return value 

68 

69 

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

71 return format_phone_number(value) 

72 

73 

74@pass_context 

75def v2date(jinja_context: JinjaContext, value: date | str) -> str: 

76 context = Context.from_jinja(jinja_context) 

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

78 value = date.fromisoformat(value) 

79 return localize_date(value, context.locale) 

80 

81 

82@pass_context 

83def v2time(jinja_context: JinjaContext, value: datetime) -> str: 

84 context = Context.from_jinja(jinja_context) 

85 value = value.astimezone(context.timezone) 

86 return localize_time(value.time(), context.locale) 

87 

88 

89@pass_context 

90def v2timestamp(jinja_context: JinjaContext, value: Timestamp) -> str: 

91 context = Context.from_jinja(jinja_context) 

92 return localize_datetime(value, context.timezone, context.locale) 

93 

94 

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

96 """ 

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

98 """ 

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

100 

101 

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

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

104 

105 

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

107 tag = match.group(1) 

108 inner_text = match.group(2) 

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

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

111 return f"<{inner_text}>" 

112 else: 

113 # <b>hello</b> -> hello 

114 return inner_text 

115 

116 

117@pass_context 

118def v2translate(jinja_context: JinjaContext, key: str, **kwargs: Any) -> str: 

119 """ 

120 Jinja2 filter to translate a string key with substitutions. 

121 

122 Usage in template: 

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

124 """ 

125 

126 context = Context.from_jinja(jinja_context) 

127 

128 # Prevent html injection 

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

130 

131 translated = localize_string(context.locale, key, substitutions=escaped_substitutions) 

132 

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

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

135 if context.plaintext: 

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

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

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

139 

140 else: 

141 # HTML support, email flavored 

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

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

144 

145 return translated 

146 

147 

148@lru_cache(maxsize=1) 

149def _get_jinja2_env() -> Environment: 

150 loader = FileSystemLoader(template_folder) 

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

152 env.filters["v2esc"] = v2esc 

153 env.filters["v2multiline"] = v2multiline 

154 env.filters["v2sf"] = v2sf 

155 env.filters["v2url"] = v2url 

156 env.filters["v2phone"] = v2phone 

157 env.filters["v2date"] = v2date 

158 env.filters["v2time"] = v2time 

159 env.filters["v2timestamp"] = v2timestamp 

160 env.filters["v2quote"] = v2quote 

161 env.filters["v2markdown"] = v2markdown 

162 env.filters["v2translate"] = v2translate 

163 return env 

164 

165 

166def render_template(template: str, args: dict[str, Any], context: Context) -> str: 

167 """Renders an a jinja2 template which may use our jinja2 filters.""" 

168 

169 # Append to the context values used by filters 

170 env = _get_jinja2_env() 

171 args = {**args, Context.KEY: context} 

172 return env.from_string(template).render(args)