@@ -149,6 +149,71 @@ def _send_html_email(
149149 )
150150
151151
152+ def _send_html_email_from_string (
153+ subject : str ,
154+ to : Iterable [str ],
155+ body_template_string : str ,
156+ context : dict ,
157+ from_email : str | None = None ,
158+ * ,
159+ notification_type : str = "other" ,
160+ submission_id : int | None = None ,
161+ ) -> None :
162+ """Like _send_html_email but renders an inline Django template string
163+ instead of loading a template file. Used when a NotificationRule has a
164+ custom ``body_template``.
165+ """
166+ from django .template import Context , Template
167+
168+ to_list = [e for e in to if e ]
169+ if not to_list :
170+ logger .info ("Skipping email '%s' (no recipients)" , subject )
171+ _write_notification_log (
172+ notification_type = notification_type ,
173+ submission_id = submission_id ,
174+ recipient_email = "(none)" ,
175+ subject = subject ,
176+ status = "skipped" ,
177+ )
178+ return
179+
180+ context .setdefault ("site_name" , _site_name ())
181+
182+ tpl = Template (body_template_string )
183+ html_body = tpl .render (Context (context ))
184+ text_body = strip_tags (html_body )
185+ from_addr = from_email or getattr (
186+ settings , "DEFAULT_FROM_EMAIL" , "no-reply@localhost"
187+ )
188+
189+ msg = EmailMultiAlternatives (
190+ subject = subject , body = text_body , from_email = from_addr , to = to_list
191+ )
192+ msg .attach_alternative (html_body , "text/html" )
193+ try :
194+ msg .send (fail_silently = False )
195+ logger .info ("Sent email '%s' to %s" , subject , to_list )
196+ for recipient in to_list :
197+ _write_notification_log (
198+ notification_type = notification_type ,
199+ submission_id = submission_id ,
200+ recipient_email = recipient ,
201+ subject = subject ,
202+ status = "sent" ,
203+ )
204+ except Exception as e : # pragma: no cover
205+ logger .exception ("Failed sending email '%s' to %s: %s" , subject , to_list , e )
206+ for recipient in to_list :
207+ _write_notification_log (
208+ notification_type = notification_type ,
209+ submission_id = submission_id ,
210+ recipient_email = recipient ,
211+ subject = subject ,
212+ status = "failed" ,
213+ error_message = str (e ),
214+ )
215+
216+
152217def _write_notification_log (
153218 * ,
154219 notification_type : str ,
@@ -726,10 +791,12 @@ def check_approval_deadlines() -> str:
726791 )
727792 if now > reminder_time :
728793 try :
729- send_approval_reminder .delay (task .id )
794+ send_notification_rules .delay (
795+ submission .id , "approval_reminder" , task_id = task .id
796+ )
730797 except Exception :
731798 logger .debug (
732- "Could not enqueue send_approval_reminder for task %s" ,
799+ "Could not enqueue approval_reminder for task %s" ,
733800 task .id ,
734801 exc_info = True ,
735802 )
@@ -1035,7 +1102,20 @@ def _effective_stage_id():
10351102 _add (email )
10361103
10371104 # 5. Stage approval groups (NotificationRule only)
1038- if getattr (notif , "notify_stage_groups" , False ) and submission is not None :
1105+ # When using the triggering stage and the task already has a specific
1106+ # individual assigned (assigned_to), skip the group notification —
1107+ # the individual was resolved via assignee_form_field so the fallback
1108+ # group should not also be notified.
1109+ _skip_stage_groups = (
1110+ getattr (notif , "use_triggering_stage" , False )
1111+ and task is not None
1112+ and getattr (task , "assigned_to_id" , None ) is not None
1113+ )
1114+ if (
1115+ getattr (notif , "notify_stage_groups" , False )
1116+ and submission is not None
1117+ and not _skip_stage_groups
1118+ ):
10391119 from django .contrib .auth import get_user_model
10401120
10411121 user_model = get_user_model ()
@@ -1117,6 +1197,8 @@ def _build_form_field_notification_context(
11171197 "workflow_approved" : "emails/approval_notification.html" ,
11181198 "workflow_denied" : "emails/rejection_notification.html" ,
11191199 "form_withdrawn" : "emails/withdrawal_notification.html" ,
1200+ "approval_reminder" : "emails/approval_reminder.html" ,
1201+ "escalation" : "emails/escalation_notification.html" ,
11201202}
11211203
11221204_EVENT_DEFAULT_SUBJECTS : dict [str , str ] = {
@@ -1126,6 +1208,8 @@ def _build_form_field_notification_context(
11261208 "workflow_approved" : "Submission Approved: {form_name} (ID {submission_id})" ,
11271209 "workflow_denied" : "Submission Rejected: {form_name} (ID {submission_id})" ,
11281210 "form_withdrawn" : "Submission Withdrawn: {form_name} (ID {submission_id})" ,
1211+ "approval_reminder" : "Reminder: Approval Pending for {form_name} (ID {submission_id})" ,
1212+ "escalation" : "Escalation: {form_name} (ID {submission_id})" ,
11291213}
11301214
11311215
@@ -1150,8 +1234,8 @@ def send_notification_rules(
11501234 event: The NotificationRule event type (e.g. ``workflow_approved``).
11511235 task_id: Optional ApprovalTask ID (used for approval_request context).
11521236 """
1153- template = _EVENT_TEMPLATE_MAP .get (event )
1154- if not template :
1237+ default_template = _EVENT_TEMPLATE_MAP .get (event )
1238+ if not default_template :
11551239 logger .warning ("send_notification_rules: unknown event '%s'" , event )
11561240 return
11571241
@@ -1298,16 +1382,28 @@ def send_notification_rules(
12981382 scheduled_for ,
12991383 )
13001384 else :
1385+ # Determine template: per-rule body_template overrides file.
1386+ rule_body = getattr (rule , "body_template" , "" ) or ""
13011387 for recipient in recipients :
13021388 ctx = {** base_context }
13031389 recipient_user = _users_by_email .get (recipient )
13041390 if recipient_user :
13051391 ctx ["approver" ] = recipient_user
1306- _send_html_email (
1307- subject ,
1308- [recipient ],
1309- template ,
1310- ctx ,
1311- notification_type = event ,
1312- submission_id = submission_id ,
1313- )
1392+ if rule_body .strip ():
1393+ _send_html_email_from_string (
1394+ subject ,
1395+ [recipient ],
1396+ rule_body ,
1397+ ctx ,
1398+ notification_type = event ,
1399+ submission_id = submission_id ,
1400+ )
1401+ else :
1402+ _send_html_email (
1403+ subject ,
1404+ [recipient ],
1405+ default_template ,
1406+ ctx ,
1407+ notification_type = event ,
1408+ submission_id = submission_id ,
1409+ )
0 commit comments