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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-13 12:05 +0000
1"""
2template mailer/push notification formatter v2
3"""
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
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
20from couchers.i18n.localize import format_phone_number, localize_date, localize_datetime, localize_string, localize_time
22logger = logging.getLogger(__name__)
24template_folder = Path(__file__).parent / ".." / ".." / ".." / "templates" / "v2"
26md = MarkdownIt("zero", {"typographer": True}).enable(["smartquotes", "heading", "hr", "list", "link", "emphasis"])
29# Special context values expected by v2 filters
30CONTEXT_YEAR_KEY = "_year"
33@dataclass(frozen=True, slots=True, kw_only=True)
34class Context:
35 """Context available to filter functions during templating."""
37 KEY: ClassVar[str] = "_filter_context"
39 timezone: ZoneInfo
40 """The timezone to use when formatting times."""
42 locale: str
43 """The locale to use when localizing strings or formatting times."""
45 plaintext: bool
46 """If true, strips html tags from localized strings."""
48 @staticmethod
49 def from_jinja(jinja_context: JinjaContext) -> Context:
50 context: Context = jinja_context[Context.KEY]
51 return context
54def v2esc(value: Any) -> str:
55 return escape(str(value))
58def v2multiline(value: str) -> str:
59 return "<br />".join(value.splitlines())
62def v2sf(value: str) -> str:
63 return value
66def v2url(value: str) -> str:
67 return value
70def v2phone(value: str) -> str:
71 return format_phone_number(value)
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)
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)
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)
95def v2quote(value: str) -> str:
96 """
97 Multiline quote, use in place of markdown in plaintext emails
98 """
99 return "\n> ".join([""] + value.splitlines())
102def v2markdown(value: str) -> str:
103 return md.render(value) # type: ignore[no-any-return]
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
117@pass_context
118def v2translate(jinja_context: JinjaContext, key: str, **kwargs: Any) -> str:
119 """
120 Jinja2 filter to translate a string key with substitutions.
122 Usage in template:
123 {{ "greeting_key"|v2translate(name=user.name) }}
124 """
126 context = Context.from_jinja(jinja_context)
128 # Prevent html injection
129 escaped_substitutions = {k: escape(str(v)) for k, v in kwargs.items()}
131 translated = localize_string(context.locale, key, substitutions=escaped_substitutions)
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)
140 else:
141 # HTML support, email flavored
142 # mjml rendering converts <br /> to <br>, so prefer that form.
143 translated = translated.replace("\n", "<br>")
145 return translated
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
166def render_template(template: str, args: dict[str, Any], context: Context) -> str:
167 """Renders an a jinja2 template which may use our jinja2 filters."""
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)