Skip to content

Commit 5e4df0e

Browse files
committed
Preview moderation emails on the admin pages
1 parent fa67ead commit 5e4df0e

File tree

5 files changed

+194
-6
lines changed

5 files changed

+194
-6
lines changed

h/routes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ def includeme(config): # noqa: PLR0915
5858
"admin.email.preview.mention_notification",
5959
"/admin/email/preview/mention-notification",
6060
)
61+
config.add_route(
62+
"admin.email.preview.moderated_annotation_notification",
63+
"/admin/email/preview/moderated-annotation-notification",
64+
)
6165
config.add_route("admin.nipsa", "/admin/nipsa")
6266
config.add_route("admin.oauthclients", "/admin/oauthclients")
6367
config.add_route("admin.oauthclients_create", "/admin/oauthclients/new")

h/services/annotation_moderation.py

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
1+
from dataclasses import asdict
2+
3+
from h_pyramid_sentry import report_exception
4+
from kombu.exceptions import OperationalError
5+
from pyramid.renderers import render
6+
7+
from h import links
18
from h.events import AnnotationAction
2-
from h.models import Annotation, ModerationLog, ModerationStatus, User
9+
from h.models import Annotation, ModerationLog, ModerationStatus, Subscriptions, User
10+
from h.services.email import EmailData, EmailTag, TaskData
11+
from h.services.subscription import SubscriptionService
12+
from h.services.user import UserService
13+
from h.tasks import email
314

415

516
class AnnotationModerationService:
6-
def __init__(self, session):
17+
def __init__(
18+
self,
19+
session,
20+
user_service: UserService,
21+
subscription_service: SubscriptionService,
22+
email_subaccount: str | None = None,
23+
):
724
self._session = session
25+
self._user_service = user_service
26+
self._subscription_service = subscription_service
27+
self._email_subaccount = email_subaccount
828

929
def all_hidden(self, annotation_ids: str) -> set[str]:
1030
"""
@@ -45,6 +65,8 @@ def set_status(
4565

4666
return moderation_log
4767

68+
return None
69+
4870
def update_status(self, action: AnnotationAction, annotation: Annotation) -> None:
4971
"""Change the moderation status of an annotation based on the action taken."""
5072
new_status = None
@@ -80,6 +102,133 @@ def update_status(self, action: AnnotationAction, annotation: Annotation) -> Non
80102

81103
self.set_status(annotation, new_status)
82104

105+
def queue_moderation_change_email(
106+
self, request, moderation_log: ModerationLog
107+
) -> None:
108+
"""Queue an email to be sent to the user about moderation changes on their annotations."""
109+
110+
annotation = moderation_log.annotation
111+
group = annotation.group
112+
author = self._user_service.fetch(annotation.userid)
113+
114+
if not group.pre_moderated:
115+
# We'll start only sending these emails for pre-moderated groups
116+
# For now this ties these emails to the FF for moderation emails
117+
return
118+
119+
if not author or not author.email:
120+
return
121+
122+
# If there is no active 'moderated' subscription for the user being mentioned.
123+
if not self._subscription_service.get_subscription(
124+
user_id=author.userid, type_=Subscriptions.Type.MODERATED
125+
).active:
126+
return
127+
128+
old_status = moderation_log.old_moderation_status
129+
new_status = moderation_log.new_moderation_status
130+
131+
# These are the transitions that will trigger an email to be sent
132+
email_sending_status_changes = {
133+
(ModerationStatus.PENDING, ModerationStatus.APPROVED),
134+
(ModerationStatus.PENDING, ModerationStatus.DENIED),
135+
(ModerationStatus.APPROVED, ModerationStatus.PENDING),
136+
(ModerationStatus.APPROVED, ModerationStatus.DENIED),
137+
(ModerationStatus.DENIED, ModerationStatus.APPROVED),
138+
(ModerationStatus.SPAM, ModerationStatus.APPROVED),
139+
}
140+
if (old_status, new_status) not in email_sending_status_changes:
141+
return
142+
143+
unsubscribe_token = self._subscription_service.get_unsubscribe_token(
144+
user_id=author.userid, type_=Subscriptions.Type.MODERATED
145+
)
146+
subject = self._email_subject(group, new_status)
147+
status_change_description = self.email_status_change_description(
148+
group, new_status
149+
)
150+
context = {
151+
"user_display_name": author.display_name or f"@{author.username}",
152+
"annotation_url": links.incontext_link(request, annotation)
153+
or request.route_url("annotation", id=annotation.id),
154+
"annotation": annotation,
155+
"annotation_quote": annotation.quote,
156+
"app_url": request.registry.settings.get("h.app_url"),
157+
"unsubscribe_url": request.route_url(
158+
"unsubscribe",
159+
token=unsubscribe_token,
160+
),
161+
"preferences_url": request.route_url("account_notifications"),
162+
"status_change_description": status_change_description,
163+
}
164+
165+
text = render(
166+
"h:templates/emails/mention_notification.txt.jinja2",
167+
context,
168+
request=request,
169+
)
170+
html = render(
171+
"h:templates/emails/mention_notification.html.jinja2",
172+
context,
173+
request=request,
174+
)
175+
176+
email_data = EmailData(
177+
recipients=[author.email],
178+
subject=subject,
179+
body=text,
180+
tag=EmailTag.MODERATED,
181+
html=html,
182+
subaccount=self._email_subaccount,
183+
)
184+
task_data = TaskData(
185+
tag=email_data.tag,
186+
sender_id=author.id,
187+
recipient_ids=[author.id],
188+
extra={"annotation_id": annotation.id},
189+
)
190+
try:
191+
email.send.delay(asdict(email_data), asdict(task_data))
192+
except OperationalError as err: # pragma: no cover
193+
report_exception(err)
194+
195+
@staticmethod
196+
def email_subject(group_name: str, new_status: ModerationStatus) -> str:
197+
"""Generate the email subject based on the moderation status change."""
198+
if new_status == ModerationStatus.DENIED:
199+
return f"Your comment in {group_name} has been declined"
200+
201+
if new_status == ModerationStatus.APPROVED:
202+
return f"Your comment in {group_name} has been approved"
203+
204+
msg = f"Unexpected moderation status change to {new_status}"
205+
raise ValueError(msg)
206+
207+
@staticmethod
208+
def email_status_change_description(
209+
group_name: str, new_status: ModerationStatus
210+
) -> str:
211+
if new_status == ModerationStatus.DENIED:
212+
return f"""The following comment has been declined by the moderation team for {group_name}.
213+
You can edit this comment and it will be reevaluated by that group's moderators."""
214+
215+
if new_status == ModerationStatus.PENDING:
216+
return f"""The following comment has been hidden by the moderation team for {group_name} and is only visible to that group's moderators and yourself.
217+
You'll receive another email when your comment's moderation status changes."""
218+
if new_status == ModerationStatus.APPROVED:
219+
return f"""The following comment has been approved by the moderation team for {group_name}.
220+
It's now visible to everyone viewing that group."""
221+
222+
msg = f"Unexpected moderation status change description for {new_status}"
223+
raise ValueError(msg)
224+
83225

84226
def annotation_moderation_service_factory(_context, request):
85-
return AnnotationModerationService(request.db)
227+
return AnnotationModerationService(
228+
request.db,
229+
user_service=request.find_service(name="user"),
230+
subscription_service=request.find_service(name="subscription_service"),
231+
email_subaccount=request.registry.settings.get(
232+
"mailchimp_user_actions_subaccount"
233+
),
234+
)

h/subscribers.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pyramid.events import BeforeRender, subscriber
77

88
from h import __version__, emails
9-
from h.events import AnnotationEvent
9+
from h.events import AnnotationEvent, ModeratedAnnotationEvent
1010
from h.exceptions import RealtimeMessageQueueError
1111
from h.models.notification import NotificationType
1212
from h.notification import mention, reply
@@ -178,3 +178,10 @@ def publish_annotation_event_for_authority(event):
178178
annotations.publish_annotation_event_for_authority.delay(
179179
event.action, event.annotation_id
180180
)
181+
182+
183+
@subscriber(ModeratedAnnotationEvent)
184+
def send_moderation_notification(event: ModeratedAnnotationEvent) -> None:
185+
event.request.find_service(
186+
name="annotation_moderation"
187+
).queue_moderation_change_email(event.moderation_log)

h/views/admin/email.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from pyramid.view import view_config
55

66
from h.emails import test
7+
from h.models.annotation import ModerationStatus
78
from h.security import Permission
9+
from h.services.annotation_moderation import AnnotationModerationService
810
from h.services.email import TaskData
911
from h.tasks import email
1012

@@ -57,3 +59,22 @@ def preview_mention_notification(_request):
5759
},
5860
"annotation_quote": "This is a very important text",
5961
}
62+
63+
64+
@view_config(
65+
route_name="admin.email.preview.moderated_annotation_notification",
66+
request_method="GET",
67+
permission=Permission.AdminPage.LOW_RISK,
68+
renderer="h:templates/emails/moderated_annotation_notification.html.jinja2",
69+
)
70+
def preview_moderated_annotation_notification(_request):
71+
return {
72+
"user_display_name": "Jane Doe",
73+
"status_change_description": AnnotationModerationService.email_status_change_description(
74+
"GROUP NAME", ModerationStatus.APPROVED
75+
),
76+
"annotation_url": "https://example.com/bouncer", # Bouncer link (AKA: annotation deeplink)
77+
"annotation": {
78+
"text_rendered": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla tincidunt malesuada ex, id dictum risus posuere sed. Curabitur risus lectus, aliquam vel tempus ut, tempus non risus. Duis ac nibh lacinia, lacinia leo sit amet, lacinia tortor. Vestibulum dictum maximus lorem, nec lobortis augue ullamcorper nec. Ut ac viverra nisi. Nam congue neque eu mi viverra ultricies. Integer pretium odio nulla, at semper dolor tincidunt quis. Pellentesque suscipit magna nec nunc mollis, a interdum purus aliquam.",
79+
},
80+
}

tests/unit/h/services/annotation_moderation_test.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from unittest.mock import sentinel
12
import pytest
23

34
from h.models.annotation import ModerationStatus
@@ -202,11 +203,17 @@ def user(self, factories, db_session):
202203

203204

204205
@pytest.fixture
205-
def svc(db_session):
206-
return AnnotationModerationService(db_session)
206+
def svc(db_session, user_service, subscription_service):
207+
return AnnotationModerationService(
208+
db_session,
209+
user_service=user_service,
210+
subscription_service=subscription_service,
211+
email_subaccount=sentinel.email_subaccount,
212+
)
207213

208214

209215
class TestAnnotationModerationServiceFactory:
216+
@pytest.mark.usefixtures("user_service", "subscription_service")
210217
def test_it_returns_service(self, pyramid_request):
211218
svc = annotation_moderation_service_factory(None, pyramid_request)
212219
assert isinstance(svc, AnnotationModerationService)

0 commit comments

Comments
 (0)