Coverage for src/couchers/notifications/background.py: 78%
96 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-20 11:53 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-20 11:53 +0000
1import logging
2from pathlib import Path
4from google.protobuf import empty_pb2
5from jinja2 import Environment, FileSystemLoader
6from sqlalchemy.orm import Session
7from sqlalchemy.sql import exists, func
9from couchers import urls
10from couchers.config import config
11from couchers.context import make_background_user_context
12from couchers.db import session_scope
13from couchers.email import queue_email
14from couchers.models import (
15 Notification,
16 NotificationDelivery,
17 NotificationDeliveryType,
18 User,
19)
20from couchers.notifications.push import push_to_user
21from couchers.notifications.quick_links import (
22 generate_do_not_email,
23 generate_unsub_topic_action,
24 generate_unsub_topic_key,
25)
26from couchers.notifications.render import render_notification
27from couchers.notifications.settings import get_preference
28from couchers.proto.internal import jobs_pb2
29from couchers.sql import couchers_select as select
30from couchers.templates.v2 import (
31 CONTEXT_PLAINTEXT_KEY,
32 CONTEXT_TIMEZONE_DISPLAY_KEY,
33 CONTEXT_TRANSLATION_COMPONENT_KEY,
34 CONTEXT_TRANSLATION_LANGUAGE_KEY,
35 CONTEXT_YEAR_KEY,
36 add_filters,
37)
38from couchers.utils import get_tz_as_text, now
40logger = logging.getLogger(__name__)
42template_folder = Path(__file__).parent / ".." / ".." / ".." / "templates" / "v2"
44loader = FileSystemLoader(template_folder)
45env = Environment(loader=loader, trim_blocks=True)
47add_filters(env)
50def _send_email_notification(session: Session, user: User, notification: Notification) -> None:
51 rendered = render_notification(user, notification)
52 template_args = {
53 "user": user,
54 "time": notification.created,
55 "footer_email_is_critical": rendered.is_critical,
56 "footer_manage_notifications_link": urls.notification_settings_link(),
57 "footer_notification_topic_action": rendered.email_topic_action_unsubscribe_text,
58 "footer_notification_topic_action_link": generate_unsub_topic_action(notification),
59 "footer_notification_topic_key": rendered.email_topic_key_unsubscribe_text,
60 "footer_notification_topic_key_link": generate_unsub_topic_key(notification),
61 "footer_do_not_email_link": generate_do_not_email(user),
62 CONTEXT_TRANSLATION_LANGUAGE_KEY: user.ui_language_preference or "en",
63 CONTEXT_TRANSLATION_COMPONENT_KEY: "notifications",
64 CONTEXT_YEAR_KEY: now().year,
65 CONTEXT_TIMEZONE_DISPLAY_KEY: get_tz_as_text(user.timezone or "Etc/UTC"),
66 **rendered.email_template_args,
67 }
69 plain_tmplt = (template_folder / f"{rendered.email_template_name}.txt").read_text()
70 plain_tmplt_footer = (template_folder / "_footer.txt").read_text()
71 plain_template_args = {**template_args, CONTEXT_PLAINTEXT_KEY: True} # Strip html from translations.
72 plain = env.from_string(plain_tmplt + plain_tmplt_footer).render(plain_template_args)
74 html_tmplt = (template_folder / "generated_html" / f"{rendered.email_template_name}.html").read_text()
75 html = env.from_string(html_tmplt).render(template_args)
77 if user.do_not_email and not rendered.is_critical:
78 logger.info(f"Not emailing {user} based on template {rendered.email_template_name} due to emails turned off")
79 return
81 if user.is_banned:
82 logger.info(f"Tried emailing {user} based on template {rendered.email_template_name} but user is banned")
83 return
85 if user.is_deleted and not rendered.allow_deleted:
86 logger.info(f"Tried emailing {user} based on template {rendered.email_template_name} but user is deleted")
87 return
89 list_unsubscribe_header = None
90 if rendered.email_list_unsubscribe_url:
91 list_unsubscribe_header = f"<{rendered.email_list_unsubscribe_url}>"
93 queue_email(
94 session,
95 sender_name=config["NOTIFICATION_EMAIL_SENDER"],
96 sender_email=config["NOTIFICATION_EMAIL_ADDRESS"],
97 recipient=user.email,
98 subject=config["NOTIFICATION_PREFIX"] + rendered.email_subject,
99 plain=plain,
100 html=html,
101 source_data=config["VERSION"] + f"/{rendered.email_template_name}",
102 list_unsubscribe_header=list_unsubscribe_header,
103 )
106def _send_push_notification(session: Session, user: User, notification: Notification) -> None:
107 logger.debug(f"Formatting push notification for {user}")
109 rendered = render_notification(user, notification)
111 if not rendered.push_title:
112 raise Exception(f"Tried to send push notification to {user} but didn't have push info")
114 push_to_user(
115 session,
116 user_id=user.id,
117 title=rendered.push_title,
118 body=rendered.push_body,
119 icon=rendered.push_icon,
120 url=rendered.push_url,
121 topic_action=notification.topic_action.display,
122 key=notification.key,
123 # keep on server for at most an hour if the client is not around
124 ttl=3600,
125 )
128def handle_notification(payload: jobs_pb2.HandleNotificationPayload) -> None:
129 with session_scope() as session:
130 notification = session.execute(
131 select(Notification).where(Notification.id == payload.notification_id)
132 ).scalar_one()
134 # Check moderation visibility if this notification is linked to moderated content
135 if notification.moderation_state_id:
136 context = make_background_user_context(notification.user_id)
137 content_visible = session.execute(
138 select(
139 exists(
140 select(Notification)
141 .where(Notification.id == notification.id)
142 .where_moderation_state_column_visible(context, Notification.moderation_state_id)
143 )
144 )
145 ).scalar_one()
147 if not content_visible:
148 # Content not visible to recipient, leave notification for later processing
149 logger.info(
150 f"Deferring notification {notification.id}: content not visible to user {notification.user_id}"
151 )
152 return
154 # ignore this notification if the user hasn't enabled new notifications
155 user = session.execute(select(User).where(User.id == notification.user_id)).scalar_one()
157 topic, action = notification.topic_action.unpack()
158 delivery_types = get_preference(session, notification.user.id, notification.topic_action)
159 for delivery_type in delivery_types:
160 # Check if delivery already exists for this notification and delivery type
161 # (this can happen if the job was queued multiple times)
162 existing_delivery = session.execute(
163 select(NotificationDelivery)
164 .where(NotificationDelivery.notification_id == notification.id)
165 .where(NotificationDelivery.delivery_type == delivery_type)
166 ).scalar_one_or_none()
167 if existing_delivery:
168 logger.info(f"Skipping {delivery_type} delivery for notification {notification.id}: already delivered")
169 continue
171 logger.info(f"Should notify by {delivery_type}")
172 if delivery_type == NotificationDeliveryType.email:
173 # for emails we don't deliver straight up, wait until the email background worker gets around to it and handles deduplication
174 session.add(
175 NotificationDelivery(
176 notification_id=notification.id,
177 delivered=func.now(),
178 delivery_type=NotificationDeliveryType.email,
179 )
180 )
181 _send_email_notification(session, user, notification)
182 elif delivery_type == NotificationDeliveryType.digest:
183 # for digest notifications, add to digest queue
184 session.add(
185 NotificationDelivery(
186 notification_id=notification.id,
187 delivered=None,
188 delivery_type=NotificationDeliveryType.digest,
189 )
190 )
191 elif delivery_type == NotificationDeliveryType.push:
192 # for push notifications, we send them straight away (web + mobile)
193 session.add(
194 NotificationDelivery(
195 notification_id=notification.id,
196 delivered=func.now(),
197 delivery_type=NotificationDeliveryType.push,
198 )
199 )
200 _send_push_notification(session, user, notification)
203def handle_email_digests(payload: empty_pb2.Empty) -> None:
204 """
205 Sends out email digests
207 The email digest is sent if the user has "digest" type notifications that have not had an individual email sent about them already.
209 If a digest is sent, then we send out every notification that has type digest, regardless of if they already got another type of notification about it.
211 That is, we don't send out an email unless there's something new, but if we do send one out, we send new and old stuff.
212 """
213 logger.info("Sending out email digests")
215 with session_scope() as session:
216 # already sent email notifications
217 delivered_email_notifications = (
218 select(
219 Notification.id.label("notification_id"),
220 # min is superfluous but needed for group_by
221 func.min(NotificationDelivery.id).label("notification_delivery_id"),
222 )
223 .join(NotificationDelivery, NotificationDelivery.notification_id == Notification.id)
224 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.email)
225 .where(NotificationDelivery.delivered != None)
226 .group_by(Notification.id)
227 .subquery()
228 )
230 # users who have unsent "digest" type notifications but not sent email notifications
231 users_to_send_digests_to = (
232 session.execute(
233 select(User)
234 .where(User.digest_frequency != None)
235 .where(User.last_digest_sent < func.now() - User.digest_frequency)
236 # todo: tz
237 .join(Notification, Notification.user_id == User.id)
238 .join(NotificationDelivery, NotificationDelivery.notification_id == Notification.id)
239 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.digest)
240 .where(NotificationDelivery.delivered == None)
241 .outerjoin(
242 delivered_email_notifications,
243 delivered_email_notifications.c.notification_id == Notification.id,
244 )
245 .where(delivered_email_notifications.c.notification_delivery_id == None)
246 .group_by(User.id)
247 )
248 .scalars()
249 .all()
250 )
252 logger.info(f"{users_to_send_digests_to=}")
254 for user in users_to_send_digests_to:
255 # digest notifications that haven't been delivered yet
256 # Exclude notifications linked to non-visible moderated content
257 context = make_background_user_context(user.id)
258 notifications_and_deliveries = session.execute(
259 select(Notification, NotificationDelivery)
260 .join(NotificationDelivery, NotificationDelivery.notification_id == Notification.id)
261 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.digest)
262 .where(NotificationDelivery.delivered == None)
263 .where(Notification.user_id == user.id)
264 .where_moderation_state_column_visible(context, Notification.moderation_state_id)
265 .order_by(Notification.created)
266 ).all()
268 if notifications_and_deliveries:
269 notifications, deliveries = zip(*notifications_and_deliveries)
270 logger.info(f"Sending {user.id=} a digest with {len(notifications)} notifications")
271 logger.info("TODO: supposed to send digest email")
272 for delivery in deliveries:
273 delivery.delivered = func.now()
274 user.last_digest_sent = func.now()
275 session.commit()