Skip to content

First version of moderation emails #9587

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions h/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ def __init__(self, request, annotation_id, action: AnnotationAction):
self.request = request
self.annotation_id = annotation_id
self.action = action


class ModeratedAnnotationEvent:
"""An event representing a moderation status change on an annotation."""

def __init__(self, request, moderation_log_id: int):
self.request = request
self.moderation_log_id = moderation_log_id
1 change: 1 addition & 0 deletions h/models/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class EmailTag(StrEnum):
RESET_PASSWORD = "reset_password" # noqa: S105
MENTION_NOTIFICATION = "mention_notification"
TEST = "test"
MODERATION = "moderation"


class Notification(Base, Timestamps): # pragma: no cover
Expand Down
1 change: 1 addition & 0 deletions h/models/subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class Type(str, Enum):

REPLY = "reply"
MENTION = "mention"
MODERATION = "moderation"

__tablename__ = "subscriptions"

Expand Down
4 changes: 4 additions & 0 deletions h/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ def includeme(config): # noqa: PLR0915
"admin.email.preview.mention_notification",
"/admin/email/preview/mention-notification",
)
config.add_route(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New route to preview the email.

"admin.email.preview.annotation_moderation_notification",
"/admin/email/preview/annotation-moderation-notification",
)
config.add_route("admin.nipsa", "/admin/nipsa")
config.add_route("admin.oauthclients", "/admin/oauthclients")
config.add_route("admin.oauthclients_create", "/admin/oauthclients/new")
Expand Down
169 changes: 158 additions & 11 deletions h/services/annotation_moderation.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
from dataclasses import asdict

from h_pyramid_sentry import report_exception
from kombu.exceptions import OperationalError
from pyramid.renderers import render

from h import links
from h.events import AnnotationAction
from h.models import Annotation, ModerationLog, ModerationStatus, User
from h.models import Annotation, ModerationLog, ModerationStatus, Subscriptions, User
from h.services.email import EmailData, EmailTag, TaskData
from h.services.subscription import SubscriptionService
from h.services.user import UserService
from h.tasks import email


class AnnotationModerationService:
def __init__(self, session):
def __init__(
self,
session,
user_service: UserService,
subscription_service: SubscriptionService,
email_subaccount: str | None = None,
):
self._session = session
self._user_service = user_service
self._subscription_service = subscription_service
self._email_subaccount = email_subaccount

def all_hidden(self, annotation_ids: str) -> set[str]:
"""
Expand All @@ -26,23 +46,27 @@ def set_status(
annotation: Annotation,
status: ModerationStatus | None,
user: User | None = None,
) -> None:
) -> ModerationLog | None:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor this method to return the resulting log entry.

"""Set the moderation status for an annotation."""
if status and status != annotation.moderation_status:
self._session.add(
ModerationLog(
annotation=annotation,
old_moderation_status=annotation.moderation_status,
new_moderation_status=status,
moderator=user,
)
moderation_log = ModerationLog(
annotation=annotation,
old_moderation_status=annotation.moderation_status,
new_moderation_status=status,
moderator=user,
)

self._session.add(moderation_log)
annotation.moderation_status = status
if annotation.slim:
# We only have to worry about AnnotationSlim if we already have one
# if we don't the process to create it will set the right value here
annotation.slim.moderated = annotation.is_hidden

return moderation_log

return None

def update_status(self, action: AnnotationAction, annotation: Annotation) -> None:
"""Change the moderation status of an annotation based on the action taken."""
new_status = None
Expand Down Expand Up @@ -78,6 +102,129 @@ def update_status(self, action: AnnotationAction, annotation: Annotation) -> Non

self.set_status(annotation, new_status)

def queue_moderation_change_email(self, request, moderation_log_id: int) -> None:
"""Queue an email to be sent to the user about moderation changes on their annotations."""

moderation_log = self._session.get(ModerationLog, moderation_log_id)

annotation = moderation_log.annotation
group = annotation.group
author = self._user_service.fetch(annotation.userid)

if not group.pre_moderated:
# We'll start only sending these emails for pre-moderated groups
# For now this ties these emails to the FF for moderation emails
return

if not author or not author.email:
# We can't email the user if we don't have an email for them.
return

# If there is no active 'moderation' subscription for the user being mentioned.
if not self._subscription_service.get_subscription(
user_id=author.userid, type_=Subscriptions.Type.MODERATION
).active:
return

old_status = moderation_log.old_moderation_status
new_status = moderation_log.new_moderation_status

# These are the transitions that will trigger an email to be sent
email_sending_status_changes = {
(ModerationStatus.PENDING, ModerationStatus.APPROVED),
(ModerationStatus.PENDING, ModerationStatus.DENIED),
(ModerationStatus.APPROVED, ModerationStatus.PENDING),
(ModerationStatus.APPROVED, ModerationStatus.DENIED),
(ModerationStatus.DENIED, ModerationStatus.APPROVED),
(ModerationStatus.SPAM, ModerationStatus.APPROVED),
}
if (old_status, new_status) not in email_sending_status_changes:
return

template_base = "h:templates/emails/annotation_moderation_notification"
context = {
"user_display_name": author.display_name or f"@{author.username}",
"annotation_url": links.incontext_link(request, annotation)
or request.route_url("annotation", id=annotation.id),
"annotation": annotation,
"annotation_quote": annotation.quote,
"app_url": request.registry.settings.get("h.app_url"),
"unsubscribe_url": request.route_url(
"unsubscribe",
token=self._subscription_service.get_unsubscribe_token(
user_id=author.userid, type_=Subscriptions.Type.MODERATION
),
),
"preferences_url": request.route_url("account_notifications"),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This URL still doesn't list this type of email, added a card to the board about it.

"status_change_description": self.email_status_change_description(
group.name, new_status
),
}
email_data = EmailData(
recipients=[author.email],
subject=self.email_subject(group.name, new_status),
body=render(f"{template_base}.txt.jinja2", context, request=request),
html=render(f"{template_base}.html.jinja2", context, request=request),
tag=EmailTag.MODERATION,
subaccount=self._email_subaccount,
)
task_data = TaskData(
tag=email_data.tag,
sender_id=author.id,
recipient_ids=[author.id],
extra={"annotation_id": annotation.id},
)
try:
email.send.delay(asdict(email_data), asdict(task_data))
except OperationalError as err: # pragma: no cover
report_exception(err)

@staticmethod
def email_subject(group_name: str, new_status: ModerationStatus) -> str:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using regular python (instead of template logic) for these.

Easier to test them IMO.

"""Generate the email subject based on the moderation status change."""
if new_status == ModerationStatus.DENIED:
return f"Your comment in {group_name} has been declined"

if new_status == ModerationStatus.APPROVED:
return f"Your comment in {group_name} has been approved"

if new_status == ModerationStatus.PENDING:
return f"Your comment in {group_name} is pending approval"

Comment on lines +186 to +193
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we say "comment" or "annotation"?

I know we use the "comment" terminology in other places, but maybe it's worth discussing with the wider group. It should not block merging this PR though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I didn't address this in a comment.

TLDR (IMO): we can merge this version, and I’ll link to this comment from the board. I already have a vague "Send moderation emails in non-pre-moderated groups" card there, but I reckon most of the work there will be this issue.

The "comment" wording is what bioRxiv wants, so we’ll start with that. This PR only sends emails for pre-moderated groups, which are only offered to bioRxiv, so this will work for them.

Of course, we’ll want to send these in all cases eventually, so we have a few options:

  • Use the "comment" wording everywhere.
    I don't like this. It would be odd if this were the only place where annotations become comments.

  • Convince bioRxiv to use “annotation” instead.
    I don't think this is likely.

  • Use the "comment" wording everywhere, but just for "page notes".
    Not ideal to use both page note and comment for the same thing, but this would be easy to implement.

  • Use different templates or a conditional to show comment for bioRxiv and annotation for others.
    While this seems like the obvious solution, I’m not sure how we’d determine this—by authority? Organization? The group the annotation is in?

msg = f"Unexpected moderation status change to {new_status}" # pragma: no cover
raise ValueError(msg)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be possible because we check the states for which we send emails but just in case we make a mistake we don't want to send an email with an empty subject or description.


@staticmethod
def email_status_change_description(
group_name: str, new_status: ModerationStatus
) -> str:
if new_status == ModerationStatus.DENIED:
return (
f"The following comment has been declined by the moderation team for {group_name}.\n"
"You can edit this comment and it will be reevaluated by that group's moderators."
)

if new_status == ModerationStatus.PENDING:
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.\n"
"You'll receive another email when your comment's moderation status changes."
)
if new_status == ModerationStatus.APPROVED:
return (
f"The following comment has been approved by the moderation team for {group_name}.\n"
"It's now visible to everyone viewing that group."
)

msg = f"Unexpected moderation status change description for {new_status}"
raise ValueError(msg)


def annotation_moderation_service_factory(_context, request):
return AnnotationModerationService(request.db)
return AnnotationModerationService(
request.db,
user_service=request.find_service(name="user"),
subscription_service=request.find_service(SubscriptionService),
email_subaccount=request.registry.settings.get(
"mailchimp_user_actions_subaccount"
),
)
9 changes: 8 additions & 1 deletion h/subscribers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pyramid.events import BeforeRender, subscriber

from h import __version__, emails
from h.events import AnnotationEvent
from h.events import AnnotationEvent, ModeratedAnnotationEvent
from h.exceptions import RealtimeMessageQueueError
from h.models.notification import NotificationType
from h.notification import mention, reply
Expand Down Expand Up @@ -178,3 +178,10 @@ def publish_annotation_event_for_authority(event):
annotations.publish_annotation_event_for_authority.delay(
event.action, event.annotation_id
)


@subscriber(ModeratedAnnotationEvent)
def send_moderation_notification(event: ModeratedAnnotationEvent) -> None:
event.request.find_service(
name="annotation_moderation"
).queue_moderation_change_email(event.request, event.moderation_log_id)
11 changes: 11 additions & 0 deletions h/templates/admin/email.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,15 @@
</iframe>
</div>

<div class="card mb-3">
<div class="card-header">
<h3 class="card-title mb-0">Preview the moderated annotation Email Template</h3>
</div>
<iframe
class="card-body p-0"
style="height: 40em; border: none;"
src="{{ request.route_url("admin.email.preview.annotation_moderation_notification") }}"
>
</iframe>
</div>
{% endblock %}
Loading
Loading