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

1import logging 

2from pathlib import Path 

3 

4from google.protobuf import empty_pb2 

5from jinja2 import Environment, FileSystemLoader 

6from sqlalchemy.orm import Session 

7from sqlalchemy.sql import exists, func 

8 

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 

39 

40logger = logging.getLogger(__name__) 

41 

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

43 

44loader = FileSystemLoader(template_folder) 

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

46 

47add_filters(env) 

48 

49 

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 } 

68 

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) 

73 

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) 

76 

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 

80 

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 

84 

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 

88 

89 list_unsubscribe_header = None 

90 if rendered.email_list_unsubscribe_url: 

91 list_unsubscribe_header = f"<{rendered.email_list_unsubscribe_url}>" 

92 

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 ) 

104 

105 

106def _send_push_notification(session: Session, user: User, notification: Notification) -> None: 

107 logger.debug(f"Formatting push notification for {user}") 

108 

109 rendered = render_notification(user, notification) 

110 

111 if not rendered.push_title: 

112 raise Exception(f"Tried to send push notification to {user} but didn't have push info") 

113 

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 ) 

126 

127 

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() 

133 

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() 

146 

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 

153 

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() 

156 

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 

170 

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) 

201 

202 

203def handle_email_digests(payload: empty_pb2.Empty) -> None: 

204 """ 

205 Sends out email digests 

206 

207 The email digest is sent if the user has "digest" type notifications that have not had an individual email sent about them already. 

208 

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. 

210 

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") 

214 

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 ) 

229 

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 ) 

251 

252 logger.info(f"{users_to_send_digests_to=}") 

253 

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() 

267 

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()