-
Notifications
You must be signed in to change notification settings - Fork 442
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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]: | ||
""" | ||
|
@@ -26,23 +46,27 @@ def set_status( | |
annotation: Annotation, | ||
status: ModerationStatus | None, | ||
user: User | None = None, | ||
) -> None: | ||
) -> ModerationLog | None: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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"), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
msg = f"Unexpected moderation status change to {new_status}" # pragma: no cover | ||
raise ValueError(msg) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
), | ||
) |
There was a problem hiding this comment.
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.