Notification emails #80

Merged
Oleg-Komarov merged 31 commits from notifications into main 2024-04-18 16:11:20 +02:00
19 changed files with 160 additions and 113 deletions
Showing only changes of commit 6f30e145cd - Show all commits

View File

@ -6,7 +6,7 @@ class AbuseConfig(AppConfig):
name = 'abuse'
def ready(self):
import abuse.signals # noqa: F401
from actstream import registry
import abuse.signals # noqa: F401
registry.register(self.get_model('AbuseReport'))

View File

@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
@receiver(post_save, sender=AbuseReport)
def create_action_from_report(
def _create_action_from_report(
sender: object,
instance: AbuseReport,
created: bool,

View File

@ -54,6 +54,7 @@ INSTALLED_APPS = [
'common',
'files',
'loginas',
'notifications',
'pipeline',
'ratings',
'rangefilter',

View File

@ -6,7 +6,7 @@ class ExtensionsConfig(AppConfig):
name = 'extensions'
def ready(self):
import extensions.signals # noqa: F401
from actstream import registry
import extensions.signals # noqa: F401
registry.register(self.get_model('Extension'))

View File

@ -1,5 +1,7 @@
from typing import Union
from actstream.actions import follow
from django.contrib.auth.models import Group
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
@ -76,3 +78,19 @@ def _set_is_listed(
extension.is_listed = new_is_listed
extension.save()
@receiver(post_save, sender=extensions.models.Extension)
def _setup_followers(
sender: object,
instance: extensions.models.Extension,
created: bool,
**kwargs: object,
) -> None:
if not created:
return
for user in instance.authors.all():
follow(user, instance, send_action=False, flag='author')
for user in Group.objects.get(name='moderators').user_set.all():
follow(user, instance, send_action=False, flag='moderator')

View File

@ -1,7 +1,5 @@
"""Contains views allowing developers to manage their add-ons."""
from actstream import action
from actstream.actions import follow
from django.contrib.auth.models import Group
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
@ -398,13 +396,6 @@ class DraftExtensionView(
extension_form.save()
add_preview_formset.save()
form.save()
# setup initial followers
authors = extension_form.instance.authors.all()
moderators = Group.objects.get(name='moderators').user_set.all()
for recipient in authors:
follow(recipient, extension_form.instance, send_action=False, flag='author')
for recipient in moderators:
follow(recipient, extension_form.instance, send_action=False, flag='moderator')
if 'submit_draft' in self.request.POST:
action.send(
self.request.user,

View File

9
notifications/apps.py Normal file
View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'notifications'
def ready(self):
import notifications.signals # noqa: F401

46
notifications/models.py Normal file
View File

@ -0,0 +1,46 @@
from actstream.models import Action
from django.contrib.auth import get_user_model
from django.db import models
from constants.activity import Verb
from common.templatetags.common import absolutify
User = get_user_model()
class Notification(models.Model):
"""Notification records are created in Action's post_save signal.
When a user marks a notification as read, a record is deleted.
While a notification exists it can be picked up by a send_notifications management command
that runs periodically in background.
If a recipient opted out from receiving notifications of particular type (based on action.verb),
we shouldn't include it in the email, leaving sent=False.
"""
recipient = models.ForeignKey(User, null=False, on_delete=models.CASCADE)
action = models.ForeignKey(Action, null=False, on_delete=models.CASCADE)
processed_by_mailer_at = models.DateTimeField(default=None, null=True)
sent = models.BooleanField(default=False, null=False)
def format_email_txt(self):
url = self.get_absolute_url()
# TODO construct a proper phrase, depending on the verb, maybe use a template
return f'{self.action.actor.full_name} {self.action.verb} {self.action.target}: {url}'
def get_absolute_url(self):
if self.action.verb == Verb.RATED_EXTENSION:
url = self.action.target.get_ratings_url()
elif self.action.verb in [
Verb.APPROVED,
Verb.COMMENTED,
Verb.REQUESTED_CHANGES,
Verb.REQUESTED_REVIEW,
]:
url = self.action.target.get_review_url()
elif self.action.action_object is not None:
url = self.action.action_object.get_absolute_url()
else:
url = self.action.target.get_absolute_url()
# TODO? url cloacking to auto-delete visited notifications
return absolutify(url)

35
notifications/signals.py Normal file
View File

@ -0,0 +1,35 @@
import logging
from actstream.models import Action, followers
from django.db.models.signals import post_save
from django.dispatch import receiver
from notifications.models import Notification
logger = logging.getLogger(__name__)
@receiver(post_save, sender=Action)
def _create_notifications(
sender: object,
instance: Action,
created: bool,
raw: bool,
**kwargs: object,
) -> None:
if raw:
return
if not created:
return
if not instance.target:
logger.warning('ignoring an unexpected Action without a target, verb={instance.verb}')
return
notifications = []
for recipient in followers(instance.target):
if recipient == instance.actor:
continue
notifications.append(Notification(recipient=recipient, action=instance))
if len(notifications) > 0:
Notification.objects.bulk_create(notifications)

View File

@ -6,7 +6,7 @@ class RatingsConfig(AppConfig):
name = 'ratings'
def ready(self):
import ratings.signals # noqa: F401
from actstream import registry
import ratings.signals # noqa: F401
registry.register(self.get_model('Rating'))

View File

@ -22,7 +22,7 @@ def _update_rating_counters(sender, instance, *args, **kwargs):
@receiver(post_save, sender=Rating)
def create_action_from_rating(
def _create_action_from_rating(
sender: object,
instance: Rating,
created: bool,

View File

@ -7,5 +7,6 @@ class ReviewersConfig(AppConfig):
def ready(self) -> None:
from actstream import registry
import reviewers.signals # noqa: F401
registry.register(self.get_model('ApprovalActivity'))

39
reviewers/signals.py Normal file
View File

@ -0,0 +1,39 @@
from actstream import action
from actstream.actions import follow
from django.db.models.signals import post_save
from django.dispatch import receiver
from constants.activity import Verb
from reviewers.models import ApprovalActivity
@receiver(post_save, sender=ApprovalActivity)
def _create_action_from_review_and_follow(
sender: object,
instance: ApprovalActivity,
created: bool,
raw: bool,
**kwargs: object,
) -> None:
if raw:
return
if not created:
return
# automatically follow after an interaction
# if a user had unfollowed this extension before,
# we are making them a follower again
follow(instance.user, instance.extension, send_action=False)
activity_type2verb = {
ApprovalActivity.ActivityType.APPROVED: Verb.APPROVED,
ApprovalActivity.ActivityType.AWAITING_CHANGES: Verb.REQUESTED_CHANGES,
ApprovalActivity.ActivityType.AWAITING_REVIEW: Verb.REQUESTED_REVIEW,
ApprovalActivity.ActivityType.COMMENT: Verb.COMMENTED,
}
action.send(
instance.user,
verb=activity_type2verb.get(instance.type),
action_object=instance,
target=instance.extension,
)

View File

@ -1,13 +1,10 @@
import logging
from actstream import action
from actstream.actions import follow
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.list import ListView
from django.views.generic import DetailView, FormView
from django.shortcuts import reverse
from constants.activity import Verb
from files.models import File
from extensions.models import Extension
from reviewers.forms import CommentForm
@ -79,24 +76,4 @@ class ExtensionsApprovalFormView(LoginRequiredMixin, FormView):
form.instance.extension = Extension.objects.get(slug=self.kwargs['slug'])
form.save()
self.approve_if_allowed(form)
# automatically follow after an interaction
# if a user had unfollowed this extension before,
# we unfortunately are making them a follower again
# TODO? set a specific flag?
follow(self.request.user, form.instance.extension, send_action=False)
activity_type2verb = {
ApprovalActivity.ActivityType.APPROVED: Verb.APPROVED,
ApprovalActivity.ActivityType.AWAITING_CHANGES: Verb.REQUESTED_CHANGES,
ApprovalActivity.ActivityType.AWAITING_REVIEW: Verb.REQUESTED_REVIEW,
ApprovalActivity.ActivityType.COMMENT: Verb.COMMENTED,
}
action.send(
self.request.user,
verb=activity_type2verb.get(form.cleaned_data['type']),
action_object=form.instance,
target=form.instance.extension,
)
return super().form_valid(form)

View File

@ -6,8 +6,8 @@ class UsersConfig(AppConfig):
verbose_name = 'Authentication and authorization'
def ready(self) -> None:
import users.signals # noqa: F401
from actstream import registry
from django.contrib.auth import get_user_model
import users.signals # noqa: F401
registry.register(get_user_model())

View File

@ -7,7 +7,7 @@ from django.core.mail import send_mail
from django.core.management.base import BaseCommand
from django.utils import timezone
from users.models import Notification
from notifications.models import Notification
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
@ -25,9 +25,9 @@ class Command(BaseCommand):
to_send = []
for n in batch:
n.processed_by_mailer_at = timezone.now()
if recipient.is_subscribed(n):
n.sent = True
to_send.append(n)
# TODO check some form of recipient.is_subscribed(n):
n.sent = True
to_send.append(n)
# first mark, then send: to avoid spamming in case of a crash-loop
Notification.objects.bulk_update(batch, ['processed_by_mailer_at', 'sent'])
if len(to_send) > 0:

View File

@ -3,14 +3,11 @@ from typing import Optional
import logging
import time
from actstream.models import Action
from django.contrib.admin.utils import NestedObjects
from django.contrib.auth.models import AbstractUser
from django.contrib.sites.models import Site
from django.db import models, DEFAULT_DB_ALIAS, transaction
from django.templatetags.static import static
from constants.activity import Verb
from common.model_mixins import TrackChangesMixin
from files.utils import get_sha256_from_value
@ -142,46 +139,3 @@ class User(TrackChangesMixin, AbstractUser):
def is_moderator(self):
# Used to review and approve extensions
return self.groups.filter(name='moderators').exists()
def is_subscribed(self, notification: 'Notification') -> bool:
# TODO user profile settings to subscribe to groups of action verbs
return True
class Notification(models.Model):
"""Notification records are created in Action's post_save signal.
When a user marks a notification as read, a record is deleted.
While a notification exists it can be picked up by a send_notifications management command
that runs periodically in background.
If a recipient opted out from receiving notifications of particular type (based on action.verb),
we shouldn't include it in the email, leaving sent=False.
"""
recipient = models.ForeignKey(User, null=False, on_delete=models.CASCADE)
action = models.ForeignKey(Action, null=False, on_delete=models.CASCADE)
processed_by_mailer_at = models.DateTimeField(default=None, null=True)
sent = models.BooleanField(default=False, null=False)
def format_email_txt(self):
url = self.get_absolute_url()
# TODO construct a proper phrase, depending on the verb, maybe use a template
return f'{self.action.actor.full_name} {self.action.verb} {self.action.target}: {url}'
def get_absolute_url(self):
if self.action.verb == Verb.RATED_EXTENSION:
url = self.action.target.get_ratings_url()
elif self.action.verb in [
Verb.APPROVED,
Verb.COMMENTED,
Verb.REQUESTED_CHANGES,
Verb.REQUESTED_REVIEW,
]:
url = self.action.target.get_review_url()
elif self.action.action_object is not None:
url = self.action.action_object.get_absolute_url()
else:
url = self.action.target.get_absolute_url()
# TODO? url cloacking to auto-delete visited notifications
domain = Site.objects.get_current().domain
return f'https://{domain}{url}'

View File

@ -2,17 +2,15 @@ from typing import Dict
import logging
from actstream.actions import follow, unfollow
from actstream.models import Action, followers
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.db.models.signals import m2m_changed, post_save, pre_save
from django.db.models.signals import m2m_changed, pre_save
from django.dispatch import receiver
from blender_id_oauth_client import signals as bid_signals
from extensions.models import Extension
from users.blender_id import BIDSession
from users.models import Notification
User = get_user_model()
bid = BIDSession()
@ -42,28 +40,6 @@ def update_user(
bid.copy_badges_from_blender_id(user=instance)
@receiver(post_save, sender=Action)
def create_notification(
sender: object,
instance: Action,
created: bool,
raw: bool,
**kwargs: object,
) -> None:
if raw:
return
if not created:
return
if not instance.target:
logger.warning('ignoring an unexpected Action without a target, verb={instance.verb}')
return
audience = filter(lambda f: f != instance.actor, followers(instance.target))
for recipient in audience:
Notification.objects.get_or_create(recipient=recipient, action=instance)
@receiver(m2m_changed, sender=User.groups.through)
def update_moderator_follows(instance, action, model, reverse, pk_set, **kwargs):
"""Users becoming moderators should follow all extensions,