Coverage for src / local_deep_research / notifications / templates.py: 50%
85 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-01-11 00:51 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2026-01-11 00:51 +0000
1"""
2Notification templates for different event types.
3"""
5from enum import Enum
6from pathlib import Path
7from typing import Dict, Any, Optional
9from jinja2 import Environment, FileSystemLoader, select_autoescape
10from loguru import logger
13class EventType(Enum):
14 """Types of events that can trigger notifications."""
16 # Research events
17 RESEARCH_COMPLETED = "research_completed"
18 RESEARCH_FAILED = "research_failed"
19 RESEARCH_QUEUED = "research_queued"
21 # Subscription events
22 SUBSCRIPTION_UPDATE = "subscription_update"
23 SUBSCRIPTION_ERROR = "subscription_error"
25 # System events
26 RATE_LIMIT_WARNING = "rate_limit_warning"
27 API_QUOTA_WARNING = "api_quota_warning"
28 AUTH_ISSUE = "auth_issue"
30 # Test event
31 TEST = "test"
34class NotificationTemplate:
35 """
36 Manages notification message templates using Jinja2.
38 Uses Jinja2 templates for consistency with the rest of the project.
39 Templates are stored in the notifications/templates/ directory.
40 """
42 # Map event types to template filenames
43 TEMPLATE_FILES: Dict[EventType, str] = {
44 EventType.RESEARCH_COMPLETED: "research_completed.jinja2",
45 EventType.RESEARCH_FAILED: "research_failed.jinja2",
46 EventType.RESEARCH_QUEUED: "research_queued.jinja2",
47 EventType.SUBSCRIPTION_UPDATE: "subscription_update.jinja2",
48 EventType.SUBSCRIPTION_ERROR: "subscription_error.jinja2",
49 EventType.API_QUOTA_WARNING: "api_quota_warning.jinja2",
50 EventType.AUTH_ISSUE: "auth_issue.jinja2",
51 EventType.TEST: "test.jinja2",
52 }
54 # Shared Jinja2 environment for all template rendering
55 _jinja_env: Optional[Environment] = None
57 @classmethod
58 def _get_jinja_env(cls) -> Environment:
59 """
60 Get or create the Jinja2 environment.
62 Returns:
63 Jinja2 Environment configured for notification templates
64 """
65 if cls._jinja_env is None:
66 # Get the templates directory relative to this file
67 template_dir = Path(__file__).parent / "templates"
69 if not template_dir.exists(): 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 logger.warning(f"Templates directory not found: {template_dir}")
71 # Fall back to simple string formatting
72 return None
74 try:
75 cls._jinja_env = Environment(
76 loader=FileSystemLoader(template_dir),
77 autoescape=select_autoescape(["html", "xml"]),
78 trim_blocks=True,
79 lstrip_blocks=True,
80 )
81 logger.debug(
82 f"Jinja2 environment initialized with templates from: {template_dir}"
83 )
84 except Exception as e:
85 logger.exception(
86 f"Failed to initialize Jinja2 environment: {e}"
87 )
88 return None
90 return cls._jinja_env
92 @classmethod
93 def format(
94 cls,
95 event_type: EventType,
96 context: Dict[str, Any],
97 custom_template: Optional[Dict[str, str]] = None,
98 ) -> Dict[str, str]:
99 """
100 Format a notification template with context data using Jinja2.
102 Args:
103 event_type: Type of event
104 context: Context data for template formatting
105 custom_template: Optional custom template override (still uses string format)
107 Returns:
108 Dict with 'title' and 'body' keys
109 """
110 # If custom template is provided, use the old string format for backward compatibility
111 if custom_template: 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true
112 try:
113 title = custom_template["title"].format(**context)
114 body = custom_template["body"].format(**context)
115 return {"title": title, "body": body}
116 except KeyError as e:
117 return {
118 "title": f"Notification: {event_type.value}",
119 "body": f"Template error: Missing variable {e}. Context: {context}",
120 }
122 # Get template filename for this event type
123 template_file = cls.TEMPLATE_FILES.get(event_type)
124 if not template_file: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 return {
126 "title": f"Notification: {event_type.value}",
127 "body": str(context),
128 }
130 # Try to render with Jinja2
131 jinja_env = cls._get_jinja_env()
132 if jinja_env is None: 132 ↛ 134line 132 didn't jump to line 134 because the condition on line 132 was never true
133 # Fallback to simple format if Jinja2 failed to initialize
134 logger.warning(
135 "Jinja2 not available, using fallback template format"
136 )
137 return cls._get_fallback_template(event_type, context)
139 try:
140 template = jinja_env.get_template(template_file)
141 rendered_content = template.render(**context)
143 # Parse the rendered content into title and body
144 lines = rendered_content.strip().split("\n")
145 title = lines[0].strip() if lines else "Notification"
146 body = "\n".join(lines[1:]).strip() if len(lines) > 1 else ""
148 return {"title": title, "body": body}
149 except Exception as e:
150 logger.exception(
151 f"Error rendering Jinja2 template {template_file}: {e}"
152 )
153 # Fall back to simple format
154 return cls._get_fallback_template(event_type, context)
156 @classmethod
157 def _get_fallback_template(
158 cls, event_type: EventType, context: Dict[str, Any]
159 ) -> Dict[str, str]:
160 """
161 Get a simple fallback template when Jinja2 is not available.
163 Args:
164 event_type: Type of event
165 context: Context data
167 Returns:
168 Dict with 'title' and 'body' keys
169 """
170 # Generic fallback that works for all event types
171 # Reduces maintenance burden - no need to update for new events
172 event_display_name = event_type.value.replace("_", " ").title()
174 return {
175 "title": f"Notification: {event_display_name}",
176 "body": f"Event: {event_display_name}\n\nDetails: {str(context)}\n\nPlease check the application for complete information.",
177 }
179 @classmethod
180 def get_required_context(cls, event_type: EventType) -> list[str]:
181 """
182 Get required context variables for an event type.
184 Args:
185 event_type: Type of event
187 Returns:
188 List of required variable names
189 """
190 template_file = cls.TEMPLATE_FILES.get(event_type)
191 if not template_file:
192 return []
194 # Try to parse Jinja2 template to get variables
195 jinja_env = cls._get_jinja_env()
196 if jinja_env is None:
197 # No Jinja2 environment available, return empty list
198 # With simplified fallback approach, we don't need to track required variables
199 return []
201 try:
202 template = jinja_env.get_template(template_file)
203 # Get variables from the parsed Jinja2 template
204 variables = set()
205 if hasattr(template, "environment"):
206 # Use Jinja2's meta module to find variables
207 from jinja2 import meta
209 template_source = template.environment.loader.get_source(
210 jinja_env, template_file
211 )[0]
212 ast = jinja_env.parse(template_source)
213 variables = meta.find_undeclared_variables(ast)
214 else:
215 # Fallback: extract from template source
216 template_source = template.environment.loader.get_source(
217 jinja_env, template_file
218 )[0]
219 import re
221 variables.update(
222 re.findall(r"\{\{\s*(\w+)\s*\}\}", template_source)
223 )
225 return sorted(variables)
226 except Exception as e:
227 logger.exception(
228 f"Error parsing Jinja2 template {template_file}: {e}"
229 )
230 return []