|
| 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 |
1 | 8 | 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 |
3 | 14 |
|
4 | 15 |
|
5 | 16 | 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 | + ): |
7 | 24 | self._session = session
|
| 25 | + self._user_service = user_service |
| 26 | + self._subscription_service = subscription_service |
| 27 | + self._email_subaccount = email_subaccount |
8 | 28 |
|
9 | 29 | def all_hidden(self, annotation_ids: str) -> set[str]:
|
10 | 30 | """
|
@@ -45,6 +65,8 @@ def set_status(
|
45 | 65 |
|
46 | 66 | return moderation_log
|
47 | 67 |
|
| 68 | + return None |
| 69 | + |
48 | 70 | def update_status(self, action: AnnotationAction, annotation: Annotation) -> None:
|
49 | 71 | """Change the moderation status of an annotation based on the action taken."""
|
50 | 72 | new_status = None
|
@@ -80,6 +102,133 @@ def update_status(self, action: AnnotationAction, annotation: Annotation) -> Non
|
80 | 102 |
|
81 | 103 | self.set_status(annotation, new_status)
|
82 | 104 |
|
| 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 | + |
83 | 225 |
|
84 | 226 | 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 | + ) |
0 commit comments